tempest-react-sdk 0.38.2 → 0.38.3

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 (291) hide show
  1. package/bin/lib/design/collect.mjs +19 -0
  2. package/bin/lib/design/functions.mjs +5 -0
  3. package/bin/lib/design/index.mjs +8 -3
  4. package/bin/lib/design/index.test.mjs +31 -0
  5. package/bin/lib/design/scan.mjs +34 -2
  6. package/bin/lib/design/scan.test.mjs +78 -0
  7. package/bin/lib/doctor/doctor.e2e.test.mjs +85 -0
  8. package/bin/tempest.mjs +90 -6
  9. package/dist/audio/level-meter.cjs.map +1 -1
  10. package/dist/audio/level-meter.js.map +1 -1
  11. package/dist/audio/use-audio-recorder.cjs.map +1 -1
  12. package/dist/audio/use-audio-recorder.js.map +1 -1
  13. package/dist/auth/passkey.cjs.map +1 -1
  14. package/dist/auth/passkey.js.map +1 -1
  15. package/dist/auth/use-passkey.cjs.map +1 -1
  16. package/dist/auth/use-passkey.js.map +1 -1
  17. package/dist/br/BrazilMap.cjs.map +1 -1
  18. package/dist/br/BrazilMap.js.map +1 -1
  19. package/dist/br/BrazilStateCitySelect.cjs.map +1 -1
  20. package/dist/br/BrazilStateCitySelect.js.map +1 -1
  21. package/dist/br/BrazilStateMap.cjs.map +1 -1
  22. package/dist/br/BrazilStateMap.js.map +1 -1
  23. package/dist/br/PixQRCode.cjs.map +1 -1
  24. package/dist/br/PixQRCode.js.map +1 -1
  25. package/dist/br/boleto.cjs.map +1 -1
  26. package/dist/br/boleto.js.map +1 -1
  27. package/dist/br/pix.cjs.map +1 -1
  28. package/dist/br/pix.js.map +1 -1
  29. package/dist/br.d.ts +8 -0
  30. package/dist/capture/media-recorder.cjs.map +1 -1
  31. package/dist/capture/media-recorder.js.map +1 -1
  32. package/dist/capture/use-barcode-scanner.cjs.map +1 -1
  33. package/dist/capture/use-barcode-scanner.js.map +1 -1
  34. package/dist/capture/use-screen-capture.cjs.map +1 -1
  35. package/dist/capture/use-screen-capture.js.map +1 -1
  36. package/dist/capture/use-speech-recognition.cjs.map +1 -1
  37. package/dist/capture/use-speech-recognition.js.map +1 -1
  38. package/dist/capture/use-video-recorder.cjs.map +1 -1
  39. package/dist/capture/use-video-recorder.js.map +1 -1
  40. package/dist/charts/types.cjs.map +1 -1
  41. package/dist/charts/types.js.map +1 -1
  42. package/dist/charts.d.ts +8 -0
  43. package/dist/components/AIChat/AIChat.cjs.map +1 -1
  44. package/dist/components/AIChat/AIChat.js.map +1 -1
  45. package/dist/components/AIChat/AIChatComposer.cjs.map +1 -1
  46. package/dist/components/AIChat/AIChatComposer.js.map +1 -1
  47. package/dist/components/AIChat/AIChatTurn.cjs.map +1 -1
  48. package/dist/components/AIChat/AIChatTurn.js.map +1 -1
  49. package/dist/components/AppBar/AppBar.cjs.map +1 -1
  50. package/dist/components/AppBar/AppBar.js.map +1 -1
  51. package/dist/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
  52. package/dist/components/AudioPlayer/AudioPlayer.js.map +1 -1
  53. package/dist/components/AudioRecorder/AudioRecorder.cjs.map +1 -1
  54. package/dist/components/AudioRecorder/AudioRecorder.js.map +1 -1
  55. package/dist/components/BarcodeScanner/BarcodeScanner.cjs.map +1 -1
  56. package/dist/components/BarcodeScanner/BarcodeScanner.js.map +1 -1
  57. package/dist/components/Button/Button.cjs.map +1 -1
  58. package/dist/components/Button/Button.js.map +1 -1
  59. package/dist/components/Calendar/Calendar.cjs.map +1 -1
  60. package/dist/components/Calendar/Calendar.js.map +1 -1
  61. package/dist/components/Carousel/Carousel.cjs.map +1 -1
  62. package/dist/components/Carousel/Carousel.js.map +1 -1
  63. package/dist/components/Chat/Chat.cjs.map +1 -1
  64. package/dist/components/Chat/Chat.js.map +1 -1
  65. package/dist/components/ChipInput/ChipInput.cjs.map +1 -1
  66. package/dist/components/ChipInput/ChipInput.js.map +1 -1
  67. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  68. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  69. package/dist/components/Combobox/Combobox.cjs.map +1 -1
  70. package/dist/components/Combobox/Combobox.js.map +1 -1
  71. package/dist/components/Command/Command.cjs.map +1 -1
  72. package/dist/components/Command/Command.js.map +1 -1
  73. package/dist/components/ConfirmDialog/ConfirmDialog.cjs.map +1 -1
  74. package/dist/components/ConfirmDialog/ConfirmDialog.js.map +1 -1
  75. package/dist/components/ContextMenu/ContextMenu.cjs.map +1 -1
  76. package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
  77. package/dist/components/CopyButton/CopyButton.cjs.map +1 -1
  78. package/dist/components/CopyButton/CopyButton.js.map +1 -1
  79. package/dist/components/DataTable/DataTable.cjs.map +1 -1
  80. package/dist/components/DataTable/DataTable.js.map +1 -1
  81. package/dist/components/DataTable/DataTable.module.cjs.map +1 -1
  82. package/dist/components/DataTable/DataTable.module.js.map +1 -1
  83. package/dist/components/DataTable/EditableCell.cjs.map +1 -1
  84. package/dist/components/DataTable/EditableCell.js.map +1 -1
  85. package/dist/components/DatePicker/DatePicker.cjs.map +1 -1
  86. package/dist/components/DatePicker/DatePicker.js.map +1 -1
  87. package/dist/components/DateRangePicker/DateRangePicker.cjs.map +1 -1
  88. package/dist/components/DateRangePicker/DateRangePicker.js.map +1 -1
  89. package/dist/components/Drawer/Drawer.cjs.map +1 -1
  90. package/dist/components/Drawer/Drawer.js.map +1 -1
  91. package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
  92. package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
  93. package/dist/components/Dropzone/Dropzone.cjs.map +1 -1
  94. package/dist/components/Dropzone/Dropzone.js.map +1 -1
  95. package/dist/components/FileUpload/FileUpload.cjs.map +1 -1
  96. package/dist/components/FileUpload/FileUpload.js.map +1 -1
  97. package/dist/components/FilterBar/FilterBar.cjs.map +1 -1
  98. package/dist/components/FilterBar/FilterBar.js.map +1 -1
  99. package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -1
  100. package/dist/components/ImageCropper/ImageCropper.js.map +1 -1
  101. package/dist/components/InstallBanner/InstallBanner.cjs.map +1 -1
  102. package/dist/components/InstallBanner/InstallBanner.js.map +1 -1
  103. package/dist/components/Kanban/Kanban.cjs.map +1 -1
  104. package/dist/components/Kanban/Kanban.js.map +1 -1
  105. package/dist/components/Lightbox/Lightbox.cjs.map +1 -1
  106. package/dist/components/Lightbox/Lightbox.js.map +1 -1
  107. package/dist/components/ListTile/ListTile.cjs.map +1 -1
  108. package/dist/components/ListTile/ListTile.js.map +1 -1
  109. package/dist/components/Markdown/Markdown.cjs.map +1 -1
  110. package/dist/components/Markdown/Markdown.js.map +1 -1
  111. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  112. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  113. package/dist/components/Menubar/Menubar.cjs.map +1 -1
  114. package/dist/components/Menubar/Menubar.js.map +1 -1
  115. package/dist/components/Modal/Modal.cjs.map +1 -1
  116. package/dist/components/Modal/Modal.js.map +1 -1
  117. package/dist/components/ModalsManager/ModalsManager.cjs.map +1 -1
  118. package/dist/components/ModalsManager/ModalsManager.js.map +1 -1
  119. package/dist/components/MultiSelect/MultiSelect.cjs.map +1 -1
  120. package/dist/components/MultiSelect/MultiSelect.js.map +1 -1
  121. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  122. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  123. package/dist/components/NotificationCenter/NotificationCenter.cjs.map +1 -1
  124. package/dist/components/NotificationCenter/NotificationCenter.js.map +1 -1
  125. package/dist/components/Page/Page.cjs.map +1 -1
  126. package/dist/components/Page/Page.js.map +1 -1
  127. package/dist/components/Pagination/Pagination.cjs.map +1 -1
  128. package/dist/components/Pagination/Pagination.js.map +1 -1
  129. package/dist/components/PasswordInput/PasswordInput.cjs.map +1 -1
  130. package/dist/components/PasswordInput/PasswordInput.js.map +1 -1
  131. package/dist/components/PinInput/PinInput.cjs.map +1 -1
  132. package/dist/components/PinInput/PinInput.js.map +1 -1
  133. package/dist/components/Popover/Popover.cjs.map +1 -1
  134. package/dist/components/Popover/Popover.js.map +1 -1
  135. package/dist/components/Progress/Progress.cjs.map +1 -1
  136. package/dist/components/Progress/Progress.js.map +1 -1
  137. package/dist/components/QRCode/qr-encode.cjs.map +1 -1
  138. package/dist/components/QRCode/qr-encode.js.map +1 -1
  139. package/dist/components/Radio/Radio.cjs.map +1 -1
  140. package/dist/components/Radio/Radio.js.map +1 -1
  141. package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
  142. package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
  143. package/dist/components/RatingStars/RatingStars.cjs.map +1 -1
  144. package/dist/components/RatingStars/RatingStars.js.map +1 -1
  145. package/dist/components/RefreshIndicator/RefreshIndicator.cjs.map +1 -1
  146. package/dist/components/RefreshIndicator/RefreshIndicator.js.map +1 -1
  147. package/dist/components/Resizable/Resizable.cjs.map +1 -1
  148. package/dist/components/Resizable/Resizable.js.map +1 -1
  149. package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
  150. package/dist/components/Scheduler/Scheduler.js.map +1 -1
  151. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  152. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  153. package/dist/components/SignaturePad/SignaturePad.cjs.map +1 -1
  154. package/dist/components/SignaturePad/SignaturePad.js.map +1 -1
  155. package/dist/components/Slider/Slider.cjs.map +1 -1
  156. package/dist/components/Slider/Slider.js.map +1 -1
  157. package/dist/components/Sparkline/Sparkline.cjs.map +1 -1
  158. package/dist/components/Sparkline/Sparkline.js.map +1 -1
  159. package/dist/components/StepperInput/StepperInput.cjs.map +1 -1
  160. package/dist/components/StepperInput/StepperInput.js.map +1 -1
  161. package/dist/components/Table/Table.cjs.map +1 -1
  162. package/dist/components/Table/Table.js.map +1 -1
  163. package/dist/components/TimePicker/TimePicker.cjs.map +1 -1
  164. package/dist/components/TimePicker/TimePicker.js.map +1 -1
  165. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  166. package/dist/components/Toast/ToastProvider.js.map +1 -1
  167. package/dist/components/Tour/Tour.cjs.map +1 -1
  168. package/dist/components/Tour/Tour.js.map +1 -1
  169. package/dist/components/Transfer/Transfer.cjs.map +1 -1
  170. package/dist/components/Transfer/Transfer.js.map +1 -1
  171. package/dist/components/TreeView/TreeView.cjs.map +1 -1
  172. package/dist/components/TreeView/TreeView.js.map +1 -1
  173. package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
  174. package/dist/components/VirtualList/VirtualList.js.map +1 -1
  175. package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
  176. package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
  177. package/dist/components/Wizard/Wizard.cjs.map +1 -1
  178. package/dist/components/Wizard/Wizard.js.map +1 -1
  179. package/dist/editor/RichTextEditor.cjs.map +1 -1
  180. package/dist/editor/RichTextEditor.js.map +1 -1
  181. package/dist/forms/FormField.cjs.map +1 -1
  182. package/dist/forms/FormField.js.map +1 -1
  183. package/dist/geo/TrajectoryMap.cjs.map +1 -1
  184. package/dist/geo/TrajectoryMap.js.map +1 -1
  185. package/dist/geo/estimate.cjs.map +1 -1
  186. package/dist/geo/estimate.js.map +1 -1
  187. package/dist/geo/projection.cjs.map +1 -1
  188. package/dist/geo/projection.js.map +1 -1
  189. package/dist/hooks/use-event-listener.cjs.map +1 -1
  190. package/dist/hooks/use-event-listener.js.map +1 -1
  191. package/dist/hooks/use-local-storage.cjs.map +1 -1
  192. package/dist/hooks/use-local-storage.js.map +1 -1
  193. package/dist/hooks/use-sortable.cjs.map +1 -1
  194. package/dist/hooks/use-sortable.js.map +1 -1
  195. package/dist/http/api-client.cjs.map +1 -1
  196. package/dist/http/api-client.js.map +1 -1
  197. package/dist/http/errors.cjs.map +1 -1
  198. package/dist/http/errors.js.map +1 -1
  199. package/dist/http/resumable-upload.cjs.map +1 -1
  200. package/dist/http/resumable-upload.js.map +1 -1
  201. package/dist/http/upload-with-progress.cjs.map +1 -1
  202. package/dist/http/upload-with-progress.js.map +1 -1
  203. package/dist/i18n/I18nProvider.cjs.map +1 -1
  204. package/dist/i18n/I18nProvider.js.map +1 -1
  205. package/dist/imaging/canvas.cjs.map +1 -1
  206. package/dist/imaging/canvas.js.map +1 -1
  207. package/dist/imaging.d.ts +5 -0
  208. package/dist/logger/logger.cjs.map +1 -1
  209. package/dist/logger/logger.js.map +1 -1
  210. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  211. package/dist/oauth/GoogleSignIn.js.map +1 -1
  212. package/dist/offline/create-offline-sync.cjs.map +1 -1
  213. package/dist/offline/create-offline-sync.js.map +1 -1
  214. package/dist/perf/cache-size.cjs +1 -1
  215. package/dist/perf/cache-size.cjs.map +1 -1
  216. package/dist/perf/cache-size.js +27 -6
  217. package/dist/perf/cache-size.js.map +1 -1
  218. package/dist/sse/create-event-stream.cjs.map +1 -1
  219. package/dist/sse/create-event-stream.js.map +1 -1
  220. package/dist/styles.css +1 -1
  221. package/dist/sw/background-sync.cjs.map +1 -1
  222. package/dist/sw/background-sync.js.map +1 -1
  223. package/dist/sw/cache.cjs.map +1 -1
  224. package/dist/sw/cache.js.map +1 -1
  225. package/dist/sw.d.ts +12 -0
  226. package/dist/tabular/compact.cjs.map +1 -1
  227. package/dist/tabular/compact.js.map +1 -1
  228. package/dist/tabular/predictor.cjs.map +1 -1
  229. package/dist/tabular/predictor.js.map +1 -1
  230. package/dist/tabular.d.ts +7 -2
  231. package/dist/tempest-react-sdk.d.ts +133 -6
  232. package/dist/theme/ThemeProvider.cjs.map +1 -1
  233. package/dist/theme/ThemeProvider.js.map +1 -1
  234. package/dist/theme/color.cjs.map +1 -1
  235. package/dist/theme/color.js.map +1 -1
  236. package/dist/theme/create-theme.cjs.map +1 -1
  237. package/dist/theme/create-theme.js.map +1 -1
  238. package/dist/theme/data-viz-ramps.cjs +1 -1
  239. package/dist/theme/data-viz-ramps.cjs.map +1 -1
  240. package/dist/theme/data-viz-ramps.js +1 -1
  241. package/dist/theme/data-viz-ramps.js.map +1 -1
  242. package/dist/utils/storage.cjs.map +1 -1
  243. package/dist/utils/storage.js.map +1 -1
  244. package/dist/vision/core/canvas.cjs.map +1 -1
  245. package/dist/vision/core/canvas.js.map +1 -1
  246. package/dist/vision/core/exceptions.cjs.map +1 -1
  247. package/dist/vision/core/exceptions.js.map +1 -1
  248. package/dist/vision/core/graph.cjs.map +1 -1
  249. package/dist/vision/core/graph.js.map +1 -1
  250. package/dist/vision/core/metadata.cjs.map +1 -1
  251. package/dist/vision/core/metadata.js.map +1 -1
  252. package/dist/vision/core/providers.cjs.map +1 -1
  253. package/dist/vision/core/providers.js.map +1 -1
  254. package/dist/vision/core/session.cjs.map +1 -1
  255. package/dist/vision/core/session.js.map +1 -1
  256. package/dist/vision/core/timing.cjs.map +1 -1
  257. package/dist/vision/core/timing.js.map +1 -1
  258. package/dist/vision/io/image.cjs.map +1 -1
  259. package/dist/vision/io/image.js.map +1 -1
  260. package/dist/vision/labels.cjs.map +1 -1
  261. package/dist/vision/labels.js.map +1 -1
  262. package/dist/vision/luminance.cjs.map +1 -1
  263. package/dist/vision/luminance.js.map +1 -1
  264. package/dist/vision/postprocess/classification.cjs.map +1 -1
  265. package/dist/vision/postprocess/classification.js.map +1 -1
  266. package/dist/vision/postprocess/detection.cjs.map +1 -1
  267. package/dist/vision/postprocess/detection.js.map +1 -1
  268. package/dist/vision/postprocess/segmentation.cjs.map +1 -1
  269. package/dist/vision/postprocess/segmentation.js.map +1 -1
  270. package/dist/vision/preprocess/image.cjs.map +1 -1
  271. package/dist/vision/preprocess/image.js.map +1 -1
  272. package/dist/vision/results.cjs.map +1 -1
  273. package/dist/vision/results.js.map +1 -1
  274. package/dist/vision/tasks/base.cjs.map +1 -1
  275. package/dist/vision/tasks/base.js.map +1 -1
  276. package/dist/vision/tasks/classifier.cjs.map +1 -1
  277. package/dist/vision/tasks/classifier.js.map +1 -1
  278. package/dist/vision/tasks/detector.cjs.map +1 -1
  279. package/dist/vision/tasks/detector.js.map +1 -1
  280. package/dist/vision/tasks/segmenter.cjs.map +1 -1
  281. package/dist/vision/tasks/segmenter.js.map +1 -1
  282. package/dist/vision/types.cjs.map +1 -1
  283. package/dist/vision/types.js.map +1 -1
  284. package/dist/vision.d.ts +6 -0
  285. package/dist/vite/tempest-icons.cjs.map +1 -1
  286. package/dist/vite/tempest-icons.js.map +1 -1
  287. package/dist/vite/tempest-pwa-icons.cjs.map +1 -1
  288. package/dist/vite/tempest-pwa-icons.js.map +1 -1
  289. package/dist/ws/create-web-socket.cjs.map +1 -1
  290. package/dist/ws/create-web-socket.js.map +1 -1
  291. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"FilterBar.js","names":[],"sources":["../../../src/components/FilterBar/FilterBar.tsx"],"sourcesContent":["import { useId, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { VisuallyHidden } from \"../VisuallyHidden\";\nimport {\n defaultOperator,\n describeFilter,\n filterStrings,\n isComplete,\n isMulti,\n isValueless,\n operatorLabel,\n operatorsFor,\n type Filter,\n type FilterField,\n} from \"./filter-model\";\nimport styles from \"./FilterBar.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onChange\";\n\nexport interface FilterBarProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** Fields the user may filter by. */\n fields: readonly FilterField[];\n /** Applied filters. Controlled. */\n value: readonly Filter[];\n /** Next filter set. Combined with AND. */\n onChange: (filters: Filter[]) => void;\n /** Locale for the labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Rendered at the end of the bar — a \"save this view\" button, a counter. */\n actions?: ReactNode;\n}\n\n/** The draft filter being built in the editor. */\ninterface Draft {\n field: string;\n operator: Filter[\"operator\"];\n value: string;\n to: string;\n}\n\n/** A fresh draft for a field. */\nfunction draftFor(field: FilterField): Draft {\n return { field: field.name, operator: defaultOperator(field), value: \"\", to: \"\" };\n}\n\n/** Turn a draft into a filter. */\nfunction toFilter(draft: Draft): Filter {\n if (isValueless(draft.operator)) return { field: draft.field, operator: draft.operator };\n if (draft.operator === \"between\") {\n return { field: draft.field, operator: draft.operator, value: [draft.value, draft.to] };\n }\n if (isMulti(draft.operator)) {\n return {\n field: draft.field,\n operator: draft.operator,\n value: draft.value\n .split(\",\")\n .map((part) => part.trim())\n .filter(Boolean),\n };\n }\n return { field: draft.field, operator: draft.operator, value: draft.value };\n}\n\n/**\n * A row of applied filters, and a small editor to add another.\n *\n * Filters are combined with **AND**, flat — no nested `(a OR b) AND c` groups. That\n * is the shape of nearly every admin list (\"status is paid, created after March,\n * text contains nota\"), and it is what serializes into a URL somebody can send to a\n * colleague. Nested groups need a tree UI with per-node operators and a different\n * serialization; a component trying to be both is clumsy at the common case.\n *\n * Controlled, like the rest of the SDK: it holds the half-typed draft in the editor\n * and nothing else. The applied set is the app's, which is what lets it come from —\n * and go back into — the URL.\n *\n * @example\n * const [filters, setFilters] = useState<Filter[]>(() =>\n * filtersFromSearchParams(new URLSearchParams(location.search), FIELDS),\n * );\n *\n * <FilterBar fields={FIELDS} value={filters} onChange={setFilters} />\n */\nexport function FilterBar({\n fields,\n value,\n onChange,\n locale = \"pt-BR\",\n actions,\n className,\n ...rest\n}: FilterBarProps) {\n const strings = filterStrings(locale);\n const baseId = useId();\n const [draft, setDraft] = useState<Draft | null>(null);\n\n const field = draft ? fields.find((candidate) => candidate.name === draft.field) : undefined;\n const pending = draft ? toFilter(draft) : null;\n const ready = pending !== null && isComplete(pending);\n\n const open = (): void => {\n if (fields.length > 0) setDraft(draftFor(fields[0]));\n };\n\n const apply = (): void => {\n if (!pending || !ready) return;\n onChange([...value, pending]);\n setDraft(null);\n };\n\n const removeAt = (index: number): void => {\n onChange(value.filter((_, position) => position !== index));\n };\n\n return (\n <div className={cn(styles.bar, className)} {...rest}>\n <ul className={styles.chips}>\n {value.length === 0 && !draft && <li className={styles.empty}>{strings.empty}</li>}\n {value.map((filter, index) => {\n const description = describeFilter(filter, fields, locale);\n return (\n <li\n key={`${filter.field}-${filter.operator}-${index}`}\n className={styles.chip}\n >\n <span>{description}</span>\n <button\n type=\"button\"\n className={styles.remove}\n aria-label={strings.remove(description)}\n onClick={() => removeAt(index)}\n >\n ×\n </button>\n </li>\n );\n })}\n </ul>\n\n {draft && field ? (\n <div className={styles.editor} role=\"group\" aria-label={strings.add}>\n <label className={styles.control}>\n <VisuallyHidden>{strings.field}</VisuallyHidden>\n <select\n className={styles.select}\n value={draft.field}\n onChange={(event) => {\n const next = fields.find(\n (candidate) => candidate.name === event.target.value,\n );\n if (next) setDraft(draftFor(next));\n }}\n >\n {fields.map((candidate) => (\n <option key={candidate.name} value={candidate.name}>\n {candidate.label}\n </option>\n ))}\n </select>\n </label>\n\n <label className={styles.control}>\n <VisuallyHidden>{strings.operator}</VisuallyHidden>\n <select\n className={styles.select}\n value={draft.operator}\n onChange={(event) =>\n setDraft({\n ...draft,\n operator: event.target.value as Filter[\"operator\"],\n value: \"\",\n to: \"\",\n })\n }\n >\n {operatorsFor(field).map((operator) => (\n <option key={operator} value={operator}>\n {operatorLabel(operator, locale)}\n </option>\n ))}\n </select>\n </label>\n\n {!isValueless(draft.operator) && (\n <ValueInput\n id={`${baseId}-value`}\n field={field}\n draft={draft}\n strings={strings}\n onChange={setDraft}\n />\n )}\n\n <button\n type=\"button\"\n className={styles.primary}\n disabled={!ready}\n onClick={apply}\n >\n {strings.apply}\n </button>\n <button type=\"button\" className={styles.ghost} onClick={() => setDraft(null)}>\n {strings.cancel}\n </button>\n </div>\n ) : (\n <div className={styles.actions}>\n <button\n type=\"button\"\n className={styles.secondary}\n disabled={fields.length === 0}\n onClick={open}\n >\n + {strings.add}\n </button>\n {value.length > 0 && (\n <button type=\"button\" className={styles.ghost} onClick={() => onChange([])}>\n {strings.clear}\n </button>\n )}\n {actions}\n </div>\n )}\n\n <VisuallyHidden aria-live=\"polite\" role=\"status\">\n {strings.active(value.length)}\n </VisuallyHidden>\n </div>\n );\n}\n\n/**\n * The value input for the draft.\n *\n * The input type follows the **field**, not the operator: a date field gets a date\n * picker even under `between`, where two of them are needed. Typing a date into a\n * text box is the fastest way to produce a filter the backend cannot parse.\n */\nfunction ValueInput({\n id,\n field,\n draft,\n strings,\n onChange,\n}: {\n id: string;\n field: FilterField;\n draft: Draft;\n strings: ReturnType<typeof filterStrings>;\n onChange: (draft: Draft) => void;\n}) {\n const type = field.type === \"number\" ? \"number\" : field.type === \"date\" ? \"date\" : \"text\";\n\n if (field.type === \"select\" && !isMulti(draft.operator)) {\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <select\n id={id}\n className={styles.select}\n value={draft.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n >\n <option value=\"\">—</option>\n {field.options?.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n </label>\n );\n }\n\n if (field.type === \"boolean\") {\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <select\n id={id}\n className={styles.select}\n value={draft.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n >\n <option value=\"\">—</option>\n <option value=\"true\">{strings.yes}</option>\n <option value=\"false\">{strings.no}</option>\n </select>\n </label>\n );\n }\n\n if (draft.operator === \"between\") {\n return (\n <>\n <label className={styles.control}>\n <VisuallyHidden>{strings.from}</VisuallyHidden>\n <input\n id={id}\n className={styles.input}\n type={type}\n value={draft.value}\n placeholder={strings.from}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n />\n </label>\n <label className={styles.control}>\n <VisuallyHidden>{strings.to}</VisuallyHidden>\n <input\n className={styles.input}\n type={type}\n value={draft.to}\n placeholder={strings.to}\n onChange={(event) => onChange({ ...draft, to: event.target.value })}\n />\n </label>\n </>\n );\n }\n\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <input\n id={id}\n className={styles.input}\n type={type}\n value={draft.value}\n placeholder={field.placeholder ?? strings.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n />\n </label>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"FilterBar.js","names":[],"sources":["../../../src/components/FilterBar/FilterBar.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — a filter bar renders a different\n * editor per filter kind — select, multi-select, range, date range, text — and the\n * body is that dispatch plus the chip summary, both reading the same value map.\n */\nimport { useId, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { VisuallyHidden } from \"../VisuallyHidden\";\nimport {\n defaultOperator,\n describeFilter,\n filterStrings,\n isComplete,\n isMulti,\n isValueless,\n operatorLabel,\n operatorsFor,\n type Filter,\n type FilterField,\n} from \"./filter-model\";\nimport styles from \"./FilterBar.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onChange\";\n\nexport interface FilterBarProps extends Omit<HTMLAttributes<HTMLDivElement>, OverriddenDomProps> {\n /** Fields the user may filter by. */\n fields: readonly FilterField[];\n /** Applied filters. Controlled. */\n value: readonly Filter[];\n /** Next filter set. Combined with AND. */\n onChange: (filters: Filter[]) => void;\n /** Locale for the labels. Default `\"pt-BR\"`. */\n locale?: \"pt-BR\" | \"en\";\n /** Rendered at the end of the bar — a \"save this view\" button, a counter. */\n actions?: ReactNode;\n}\n\n/** The draft filter being built in the editor. */\ninterface Draft {\n field: string;\n operator: Filter[\"operator\"];\n value: string;\n to: string;\n}\n\n/** A fresh draft for a field. */\nfunction draftFor(field: FilterField): Draft {\n return { field: field.name, operator: defaultOperator(field), value: \"\", to: \"\" };\n}\n\n/** Turn a draft into a filter. */\nfunction toFilter(draft: Draft): Filter {\n if (isValueless(draft.operator)) return { field: draft.field, operator: draft.operator };\n if (draft.operator === \"between\") {\n return { field: draft.field, operator: draft.operator, value: [draft.value, draft.to] };\n }\n if (isMulti(draft.operator)) {\n return {\n field: draft.field,\n operator: draft.operator,\n value: draft.value\n .split(\",\")\n .map((part) => part.trim())\n .filter(Boolean),\n };\n }\n return { field: draft.field, operator: draft.operator, value: draft.value };\n}\n\n/**\n * A row of applied filters, and a small editor to add another.\n *\n * Filters are combined with **AND**, flat — no nested `(a OR b) AND c` groups. That\n * is the shape of nearly every admin list (\"status is paid, created after March,\n * text contains nota\"), and it is what serializes into a URL somebody can send to a\n * colleague. Nested groups need a tree UI with per-node operators and a different\n * serialization; a component trying to be both is clumsy at the common case.\n *\n * Controlled, like the rest of the SDK: it holds the half-typed draft in the editor\n * and nothing else. The applied set is the app's, which is what lets it come from —\n * and go back into — the URL.\n *\n * @example\n * const [filters, setFilters] = useState<Filter[]>(() =>\n * filtersFromSearchParams(new URLSearchParams(location.search), FIELDS),\n * );\n *\n * <FilterBar fields={FIELDS} value={filters} onChange={setFilters} />\n */\nexport function FilterBar({\n fields,\n value,\n onChange,\n locale = \"pt-BR\",\n actions,\n className,\n ...rest\n}: FilterBarProps) {\n const strings = filterStrings(locale);\n const baseId = useId();\n const [draft, setDraft] = useState<Draft | null>(null);\n\n const field = draft ? fields.find((candidate) => candidate.name === draft.field) : undefined;\n const pending = draft ? toFilter(draft) : null;\n const ready = pending !== null && isComplete(pending);\n\n const open = (): void => {\n if (fields.length > 0) setDraft(draftFor(fields[0]));\n };\n\n const apply = (): void => {\n if (!pending || !ready) return;\n onChange([...value, pending]);\n setDraft(null);\n };\n\n const removeAt = (index: number): void => {\n onChange(value.filter((_, position) => position !== index));\n };\n\n return (\n <div className={cn(styles.bar, className)} {...rest}>\n <ul className={styles.chips}>\n {value.length === 0 && !draft && <li className={styles.empty}>{strings.empty}</li>}\n {value.map((filter, index) => {\n const description = describeFilter(filter, fields, locale);\n return (\n <li\n key={`${filter.field}-${filter.operator}-${index}`}\n className={styles.chip}\n >\n <span>{description}</span>\n <button\n type=\"button\"\n className={styles.remove}\n aria-label={strings.remove(description)}\n onClick={() => removeAt(index)}\n >\n ×\n </button>\n </li>\n );\n })}\n </ul>\n\n {draft && field ? (\n <div className={styles.editor} role=\"group\" aria-label={strings.add}>\n <label className={styles.control}>\n <VisuallyHidden>{strings.field}</VisuallyHidden>\n <select\n className={styles.select}\n value={draft.field}\n onChange={(event) => {\n const next = fields.find(\n (candidate) => candidate.name === event.target.value,\n );\n if (next) setDraft(draftFor(next));\n }}\n >\n {fields.map((candidate) => (\n <option key={candidate.name} value={candidate.name}>\n {candidate.label}\n </option>\n ))}\n </select>\n </label>\n\n <label className={styles.control}>\n <VisuallyHidden>{strings.operator}</VisuallyHidden>\n <select\n className={styles.select}\n value={draft.operator}\n onChange={(event) =>\n setDraft({\n ...draft,\n operator: event.target.value as Filter[\"operator\"],\n value: \"\",\n to: \"\",\n })\n }\n >\n {operatorsFor(field).map((operator) => (\n <option key={operator} value={operator}>\n {operatorLabel(operator, locale)}\n </option>\n ))}\n </select>\n </label>\n\n {!isValueless(draft.operator) && (\n <ValueInput\n id={`${baseId}-value`}\n field={field}\n draft={draft}\n strings={strings}\n onChange={setDraft}\n />\n )}\n\n <button\n type=\"button\"\n className={styles.primary}\n disabled={!ready}\n onClick={apply}\n >\n {strings.apply}\n </button>\n <button type=\"button\" className={styles.ghost} onClick={() => setDraft(null)}>\n {strings.cancel}\n </button>\n </div>\n ) : (\n <div className={styles.actions}>\n <button\n type=\"button\"\n className={styles.secondary}\n disabled={fields.length === 0}\n onClick={open}\n >\n + {strings.add}\n </button>\n {value.length > 0 && (\n <button type=\"button\" className={styles.ghost} onClick={() => onChange([])}>\n {strings.clear}\n </button>\n )}\n {actions}\n </div>\n )}\n\n <VisuallyHidden aria-live=\"polite\" role=\"status\">\n {strings.active(value.length)}\n </VisuallyHidden>\n </div>\n );\n}\n\n/**\n * The value input for the draft.\n *\n * The input type follows the **field**, not the operator: a date field gets a date\n * picker even under `between`, where two of them are needed. Typing a date into a\n * text box is the fastest way to produce a filter the backend cannot parse.\n */\nfunction ValueInput({\n id,\n field,\n draft,\n strings,\n onChange,\n}: {\n id: string;\n field: FilterField;\n draft: Draft;\n strings: ReturnType<typeof filterStrings>;\n onChange: (draft: Draft) => void;\n}) {\n const type = field.type === \"number\" ? \"number\" : field.type === \"date\" ? \"date\" : \"text\";\n\n if (field.type === \"select\" && !isMulti(draft.operator)) {\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <select\n id={id}\n className={styles.select}\n value={draft.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n >\n <option value=\"\">—</option>\n {field.options?.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n </label>\n );\n }\n\n if (field.type === \"boolean\") {\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <select\n id={id}\n className={styles.select}\n value={draft.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n >\n <option value=\"\">—</option>\n <option value=\"true\">{strings.yes}</option>\n <option value=\"false\">{strings.no}</option>\n </select>\n </label>\n );\n }\n\n if (draft.operator === \"between\") {\n return (\n <>\n <label className={styles.control}>\n <VisuallyHidden>{strings.from}</VisuallyHidden>\n <input\n id={id}\n className={styles.input}\n type={type}\n value={draft.value}\n placeholder={strings.from}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n />\n </label>\n <label className={styles.control}>\n <VisuallyHidden>{strings.to}</VisuallyHidden>\n <input\n className={styles.input}\n type={type}\n value={draft.to}\n placeholder={strings.to}\n onChange={(event) => onChange({ ...draft, to: event.target.value })}\n />\n </label>\n </>\n );\n }\n\n return (\n <label className={styles.control}>\n <VisuallyHidden>{strings.value}</VisuallyHidden>\n <input\n id={id}\n className={styles.input}\n type={type}\n value={draft.value}\n placeholder={field.placeholder ?? strings.value}\n onChange={(event) => onChange({ ...draft, value: event.target.value })}\n />\n </label>\n );\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ImageCropper.cjs","names":[],"sources":["../../../src/components/ImageCropper/ImageCropper.tsx"],"sourcesContent":["import {\n type HTMLAttributes,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport {\n clampOffset,\n computeCropRect,\n coverScale,\n type Offset,\n outputSize,\n type Size,\n} from \"./crop-geometry\";\nimport styles from \"./ImageCropper.module.css\";\n\n/** Imperative handle for exporting the current crop. */\nexport interface ImageCropperHandle {\n /**\n * Render the current crop and resolve with it.\n *\n * Resolves `null` when the image has not loaded yet or the browser refuses to\n * encode — never throws, so a submit handler does not need a try/catch.\n */\n crop: () => Promise<Blob | null>;\n /** Recentre at zoom 1. */\n reset: () => void;\n}\n\nexport interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** The image to crop: a `File`/`Blob` from an input, or a URL. */\n src: File | Blob | string;\n /** Crop aspect ratio as `width / height`. Default `1` (square). */\n aspect?: number;\n /** Maximum zoom over the cover scale. Default `4`. */\n maxZoom?: number;\n /**\n * Cap on the longest edge of the exported image, in px.\n *\n * Without it the export keeps the source resolution, which is right for a\n * document scan and wasteful for an avatar.\n */\n maxSize?: number;\n /** Output MIME type. Default `\"image/png\"`. */\n outputType?: string;\n /** Output quality for lossy types, `0`–`1`. Default `0.92`. */\n outputQuality?: number;\n /** Overlay shape. `\"circle\"` for an avatar, `\"rect\"` for a document. Default `\"rect\"`. */\n shape?: \"rect\" | \"circle\";\n /** Called whenever the crop changes, e.g. to enable a submit button. */\n onCropChange?: (state: { zoom: number; offset: Offset }) => void;\n /** Accessible name for the crop area. */\n label?: string;\n ref?: React.Ref<ImageCropperHandle>;\n}\n\n/** Pixels moved per arrow-key press. */\nconst KEY_STEP = 12;\n\n/** Zoom added or removed per `+`/`-` press, and per wheel notch. */\nconst ZOOM_STEP = 0.15;\n\n/**\n * Crop an image to a fixed aspect ratio.\n *\n * The frame stays put and the image pans and zooms behind it — the model an avatar\n * or document-photo flow wants, where the output shape is decided by the app and\n * the user only chooses what lands inside it. (A free-form draggable rectangle is a\n * different component; this one cannot produce an off-ratio crop by construction.)\n *\n * The export reads the *natural* pixels through a canvas, so a 4000 px photo is not\n * silently downsampled to whatever the on-screen preview measured. The image is\n * also always clamped to cover the frame, so an export can never contain the empty\n * bands you get from panning past an edge.\n *\n * Works by pointer, wheel and keyboard: arrows pan, `+`/`-` zoom, `0` resets.\n *\n * @example\n * const cropper = useRef<ImageCropperHandle>(null);\n *\n * <ImageCropper ref={cropper} src={file} aspect={1} shape=\"circle\" maxSize={512} />\n * <Button onClick={async () => upload(await cropper.current?.crop())}>Salvar</Button>\n */\nexport function ImageCropper({\n src,\n aspect = 1,\n maxZoom = 4,\n maxSize,\n outputType = \"image/png\",\n outputQuality = 0.92,\n shape = \"rect\",\n onCropChange,\n label = \"Área de recorte\",\n className,\n ref,\n ...rest\n}: ImageCropperProps) {\n /**\n * Id for the shortcut hint.\n *\n * Generated rather than derived from `label`: a label with spaces would produce\n * an id with spaces, and `aria-describedby` splits on whitespace — so the\n * description would silently never associate with the frame.\n */\n const hintId = `${useId()}-hint`;\n\n const frameRef = useRef<HTMLDivElement>(null);\n const imageRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<{\n pointerId: number;\n startX: number;\n startY: number;\n from: Offset;\n } | null>(null);\n\n const [url, setUrl] = useState<string | null>(null);\n const [natural, setNatural] = useState<Size | null>(null);\n const [frame, setFrame] = useState<Size>({ width: 0, height: 0 });\n const [zoom, setZoom] = useState(1);\n const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });\n\n /**\n * Resolve `src` to a displayable URL.\n *\n * A `File`/`Blob` needs `createObjectURL`, and the URL must be revoked when the\n * source changes or the component unmounts — otherwise every re-pick of a photo\n * leaks the previous one for the lifetime of the document.\n */\n useEffect(() => {\n if (typeof src === \"string\") {\n setUrl(src);\n return;\n }\n const objectUrl = URL.createObjectURL(src);\n setUrl(objectUrl);\n return () => URL.revokeObjectURL(objectUrl);\n }, [src]);\n\n // A new source invalidates the previous framing.\n useEffect(() => {\n setNatural(null);\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n }, [url]);\n\n /** Track the frame's rendered size — the crop math is all relative to it. */\n useEffect(() => {\n const element = frameRef.current;\n if (!element) return;\n const measure = () =>\n setFrame({ width: element.clientWidth, height: element.clientHeight });\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n const scale = natural ? coverScale(natural, frame) * zoom : 0;\n\n /**\n * On-screen image size.\n *\n * Memoized because the pan callback depends on it: a fresh object every render\n * would rebuild that callback every render, and the pointer handlers close over\n * it during a drag.\n */\n const displayed = useMemo<Size>(\n () =>\n natural\n ? { width: natural.width * scale, height: natural.height * scale }\n : { width: 0, height: 0 },\n [natural, scale],\n );\n\n /** Apply a pan, clamped so the frame stays covered. */\n const pan = useCallback(\n (next: Offset) => {\n setOffset((current) => {\n const clamped = clampOffset(next, displayed, frame);\n if (clamped.x === current.x && clamped.y === current.y) return current;\n onCropChange?.({ zoom, offset: clamped });\n return clamped;\n });\n },\n [displayed, frame, onCropChange, zoom],\n );\n\n /**\n * Apply a zoom, then re-clamp the offset.\n *\n * Re-clamping is not optional: zooming *out* shrinks the image, so an offset\n * that was legal a moment ago can now expose background.\n */\n const applyZoom = useCallback(\n (next: number) => {\n const clampedZoom = Math.min(maxZoom, Math.max(1, next));\n setZoom(clampedZoom);\n if (!natural) return;\n const nextScale = coverScale(natural, frame) * clampedZoom;\n const nextDisplayed = {\n width: natural.width * nextScale,\n height: natural.height * nextScale,\n };\n setOffset((current) => {\n const clamped = clampOffset(current, nextDisplayed, frame);\n onCropChange?.({ zoom: clampedZoom, offset: clamped });\n return clamped;\n });\n },\n [frame, maxZoom, natural, onCropChange],\n );\n\n const reset = useCallback(() => {\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n onCropChange?.({ zoom: 1, offset: { x: 0, y: 0 } });\n }, [onCropChange]);\n\n /**\n * Draw the current crop and encode it.\n *\n * Returns `null` rather than throwing on the paths a caller cannot do anything\n * about: no image yet, no 2D context, or an encoder that declined.\n */\n const crop = useCallback(async (): Promise<Blob | null> => {\n const image = imageRef.current;\n if (!image || !natural) return null;\n\n const rect = computeCropRect({ image: natural, frame, zoom, offset });\n if (rect.sWidth <= 0 || rect.sHeight <= 0) return null;\n\n const out = outputSize(rect, maxSize);\n const canvas = document.createElement(\"canvas\");\n canvas.width = out.width;\n canvas.height = out.height;\n const context = canvas.getContext(\"2d\");\n if (!context) return null;\n\n context.drawImage(\n image,\n rect.sx,\n rect.sy,\n rect.sWidth,\n rect.sHeight,\n 0,\n 0,\n out.width,\n out.height,\n );\n\n return new Promise((resolve) => {\n canvas.toBlob((blob) => resolve(blob), outputType, outputQuality);\n });\n }, [frame, maxSize, natural, offset, outputQuality, outputType, zoom]);\n\n useImperativeHandle(ref, () => ({ crop, reset }), [crop, reset]);\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (!natural) return;\n dragRef.current = {\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n from: offset,\n };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== event.pointerId) return;\n pan({\n x: drag.from.x + (event.clientX - drag.startX),\n y: drag.from.y + (event.clientY - drag.startY),\n });\n };\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (dragRef.current?.pointerId !== event.pointerId) return;\n dragRef.current = null;\n };\n\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {\n const step = event.shiftKey ? KEY_STEP * 4 : KEY_STEP;\n const moves: Record<string, Offset> = {\n ArrowLeft: { x: -step, y: 0 },\n ArrowRight: { x: step, y: 0 },\n ArrowUp: { x: 0, y: -step },\n ArrowDown: { x: 0, y: step },\n };\n const move = moves[event.key];\n if (move) {\n event.preventDefault();\n pan({ x: offset.x + move.x, y: offset.y + move.y });\n return;\n }\n if (event.key === \"+\" || event.key === \"=\") {\n event.preventDefault();\n applyZoom(zoom + ZOOM_STEP);\n } else if (event.key === \"-\" || event.key === \"_\") {\n event.preventDefault();\n applyZoom(zoom - ZOOM_STEP);\n } else if (event.key === \"0\") {\n event.preventDefault();\n reset();\n }\n };\n\n return (\n <div className={cn(styles.wrapper, className)} {...rest}>\n <div\n ref={frameRef}\n className={cn(styles.frame, shape === \"circle\" && styles.circle)}\n style={{ aspectRatio: String(aspect) }}\n role=\"group\"\n aria-label={label}\n aria-describedby={hintId}\n tabIndex={0}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={onKeyDown}\n onWheel={(event) => {\n event.preventDefault();\n applyZoom(zoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));\n }}\n >\n {url && (\n <img\n ref={imageRef}\n src={url}\n alt=\"\"\n draggable={false}\n className={styles.image}\n style={{\n width: displayed.width || undefined,\n height: displayed.height || undefined,\n transform: `translate(${offset.x}px, ${offset.y}px)`,\n }}\n onLoad={(event) => {\n const element = event.currentTarget;\n // A decode can succeed and still report no intrinsic size —\n // an SVG without a viewBox is the common case. Accepting\n // that would enable the controls over an image the crop\n // maths can do nothing with.\n if (element.naturalWidth > 0 && element.naturalHeight > 0) {\n setNatural({\n width: element.naturalWidth,\n height: element.naturalHeight,\n });\n }\n }}\n />\n )}\n </div>\n\n <div className={styles.controls}>\n <input\n type=\"range\"\n className={styles.zoom}\n min={1}\n max={maxZoom}\n step={0.01}\n value={zoom}\n onChange={(event) => applyZoom(Number(event.target.value))}\n aria-label=\"Zoom\"\n disabled={!natural}\n />\n <button type=\"button\" className={styles.reset} onClick={reset} disabled={!natural}>\n Centralizar\n </button>\n </div>\n\n <p id={hintId} className={styles.hint}>\n Arraste para reposicionar. Setas movem, <kbd>+</kbd> e <kbd>−</kbd> dão zoom,{\" \"}\n <kbd>0</kbd> centraliza.\n </p>\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ImageCropper.cjs","names":[],"sources":["../../../src/components/ImageCropper/ImageCropper.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — pointer drag, wheel\n * zoom, aspect clamping and canvas export share one piece of geometry state, and the\n * props are the two halves of that: the frame (aspect, shape, maxZoom, label) and\n * the export (maxSize, outputType, outputQuality, onCropChange, ref). Threading the\n * geometry through props would duplicate the clamp maths.\n */\nimport {\n type HTMLAttributes,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport {\n clampOffset,\n computeCropRect,\n coverScale,\n type Offset,\n outputSize,\n type Size,\n} from \"./crop-geometry\";\nimport styles from \"./ImageCropper.module.css\";\n\n/** Imperative handle for exporting the current crop. */\nexport interface ImageCropperHandle {\n /**\n * Render the current crop and resolve with it.\n *\n * Resolves `null` when the image has not loaded yet or the browser refuses to\n * encode — never throws, so a submit handler does not need a try/catch.\n */\n crop: () => Promise<Blob | null>;\n /** Recentre at zoom 1. */\n reset: () => void;\n}\n\nexport interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** The image to crop: a `File`/`Blob` from an input, or a URL. */\n src: File | Blob | string;\n /** Crop aspect ratio as `width / height`. Default `1` (square). */\n aspect?: number;\n /** Maximum zoom over the cover scale. Default `4`. */\n maxZoom?: number;\n /**\n * Cap on the longest edge of the exported image, in px.\n *\n * Without it the export keeps the source resolution, which is right for a\n * document scan and wasteful for an avatar.\n */\n maxSize?: number;\n /** Output MIME type. Default `\"image/png\"`. */\n outputType?: string;\n /** Output quality for lossy types, `0`–`1`. Default `0.92`. */\n outputQuality?: number;\n /** Overlay shape. `\"circle\"` for an avatar, `\"rect\"` for a document. Default `\"rect\"`. */\n shape?: \"rect\" | \"circle\";\n /** Called whenever the crop changes, e.g. to enable a submit button. */\n onCropChange?: (state: { zoom: number; offset: Offset }) => void;\n /** Accessible name for the crop area. */\n label?: string;\n ref?: React.Ref<ImageCropperHandle>;\n}\n\n/** Pixels moved per arrow-key press. */\nconst KEY_STEP = 12;\n\n/** Zoom added or removed per `+`/`-` press, and per wheel notch. */\nconst ZOOM_STEP = 0.15;\n\n/**\n * Crop an image to a fixed aspect ratio.\n *\n * The frame stays put and the image pans and zooms behind it — the model an avatar\n * or document-photo flow wants, where the output shape is decided by the app and\n * the user only chooses what lands inside it. (A free-form draggable rectangle is a\n * different component; this one cannot produce an off-ratio crop by construction.)\n *\n * The export reads the *natural* pixels through a canvas, so a 4000 px photo is not\n * silently downsampled to whatever the on-screen preview measured. The image is\n * also always clamped to cover the frame, so an export can never contain the empty\n * bands you get from panning past an edge.\n *\n * Works by pointer, wheel and keyboard: arrows pan, `+`/`-` zoom, `0` resets.\n *\n * @example\n * const cropper = useRef<ImageCropperHandle>(null);\n *\n * <ImageCropper ref={cropper} src={file} aspect={1} shape=\"circle\" maxSize={512} />\n * <Button onClick={async () => upload(await cropper.current?.crop())}>Salvar</Button>\n */\nexport function ImageCropper({\n src,\n aspect = 1,\n maxZoom = 4,\n maxSize,\n outputType = \"image/png\",\n outputQuality = 0.92,\n shape = \"rect\",\n onCropChange,\n label = \"Área de recorte\",\n className,\n ref,\n ...rest\n}: ImageCropperProps) {\n /**\n * Id for the shortcut hint.\n *\n * Generated rather than derived from `label`: a label with spaces would produce\n * an id with spaces, and `aria-describedby` splits on whitespace — so the\n * description would silently never associate with the frame.\n */\n const hintId = `${useId()}-hint`;\n\n const frameRef = useRef<HTMLDivElement>(null);\n const imageRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<{\n pointerId: number;\n startX: number;\n startY: number;\n from: Offset;\n } | null>(null);\n\n const [url, setUrl] = useState<string | null>(null);\n const [natural, setNatural] = useState<Size | null>(null);\n const [frame, setFrame] = useState<Size>({ width: 0, height: 0 });\n const [zoom, setZoom] = useState(1);\n const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });\n\n /**\n * Resolve `src` to a displayable URL.\n *\n * A `File`/`Blob` needs `createObjectURL`, and the URL must be revoked when the\n * source changes or the component unmounts — otherwise every re-pick of a photo\n * leaks the previous one for the lifetime of the document.\n */\n useEffect(() => {\n if (typeof src === \"string\") {\n setUrl(src);\n return;\n }\n const objectUrl = URL.createObjectURL(src);\n setUrl(objectUrl);\n return () => URL.revokeObjectURL(objectUrl);\n }, [src]);\n\n // A new source invalidates the previous framing.\n useEffect(() => {\n setNatural(null);\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n }, [url]);\n\n /** Track the frame's rendered size — the crop math is all relative to it. */\n useEffect(() => {\n const element = frameRef.current;\n if (!element) return;\n const measure = () =>\n setFrame({ width: element.clientWidth, height: element.clientHeight });\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n const scale = natural ? coverScale(natural, frame) * zoom : 0;\n\n /**\n * On-screen image size.\n *\n * Memoized because the pan callback depends on it: a fresh object every render\n * would rebuild that callback every render, and the pointer handlers close over\n * it during a drag.\n */\n const displayed = useMemo<Size>(\n () =>\n natural\n ? { width: natural.width * scale, height: natural.height * scale }\n : { width: 0, height: 0 },\n [natural, scale],\n );\n\n /** Apply a pan, clamped so the frame stays covered. */\n const pan = useCallback(\n (next: Offset) => {\n setOffset((current) => {\n const clamped = clampOffset(next, displayed, frame);\n if (clamped.x === current.x && clamped.y === current.y) return current;\n onCropChange?.({ zoom, offset: clamped });\n return clamped;\n });\n },\n [displayed, frame, onCropChange, zoom],\n );\n\n /**\n * Apply a zoom, then re-clamp the offset.\n *\n * Re-clamping is not optional: zooming *out* shrinks the image, so an offset\n * that was legal a moment ago can now expose background.\n */\n const applyZoom = useCallback(\n (next: number) => {\n const clampedZoom = Math.min(maxZoom, Math.max(1, next));\n setZoom(clampedZoom);\n if (!natural) return;\n const nextScale = coverScale(natural, frame) * clampedZoom;\n const nextDisplayed = {\n width: natural.width * nextScale,\n height: natural.height * nextScale,\n };\n setOffset((current) => {\n const clamped = clampOffset(current, nextDisplayed, frame);\n onCropChange?.({ zoom: clampedZoom, offset: clamped });\n return clamped;\n });\n },\n [frame, maxZoom, natural, onCropChange],\n );\n\n const reset = useCallback(() => {\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n onCropChange?.({ zoom: 1, offset: { x: 0, y: 0 } });\n }, [onCropChange]);\n\n /**\n * Draw the current crop and encode it.\n *\n * Returns `null` rather than throwing on the paths a caller cannot do anything\n * about: no image yet, no 2D context, or an encoder that declined.\n */\n const crop = useCallback(async (): Promise<Blob | null> => {\n const image = imageRef.current;\n if (!image || !natural) return null;\n\n const rect = computeCropRect({ image: natural, frame, zoom, offset });\n if (rect.sWidth <= 0 || rect.sHeight <= 0) return null;\n\n const out = outputSize(rect, maxSize);\n const canvas = document.createElement(\"canvas\");\n canvas.width = out.width;\n canvas.height = out.height;\n const context = canvas.getContext(\"2d\");\n if (!context) return null;\n\n context.drawImage(\n image,\n rect.sx,\n rect.sy,\n rect.sWidth,\n rect.sHeight,\n 0,\n 0,\n out.width,\n out.height,\n );\n\n return new Promise((resolve) => {\n canvas.toBlob((blob) => resolve(blob), outputType, outputQuality);\n });\n }, [frame, maxSize, natural, offset, outputQuality, outputType, zoom]);\n\n useImperativeHandle(ref, () => ({ crop, reset }), [crop, reset]);\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (!natural) return;\n dragRef.current = {\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n from: offset,\n };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== event.pointerId) return;\n pan({\n x: drag.from.x + (event.clientX - drag.startX),\n y: drag.from.y + (event.clientY - drag.startY),\n });\n };\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (dragRef.current?.pointerId !== event.pointerId) return;\n dragRef.current = null;\n };\n\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {\n const step = event.shiftKey ? KEY_STEP * 4 : KEY_STEP;\n const moves: Record<string, Offset> = {\n ArrowLeft: { x: -step, y: 0 },\n ArrowRight: { x: step, y: 0 },\n ArrowUp: { x: 0, y: -step },\n ArrowDown: { x: 0, y: step },\n };\n const move = moves[event.key];\n if (move) {\n event.preventDefault();\n pan({ x: offset.x + move.x, y: offset.y + move.y });\n return;\n }\n if (event.key === \"+\" || event.key === \"=\") {\n event.preventDefault();\n applyZoom(zoom + ZOOM_STEP);\n } else if (event.key === \"-\" || event.key === \"_\") {\n event.preventDefault();\n applyZoom(zoom - ZOOM_STEP);\n } else if (event.key === \"0\") {\n event.preventDefault();\n reset();\n }\n };\n\n return (\n <div className={cn(styles.wrapper, className)} {...rest}>\n <div\n ref={frameRef}\n className={cn(styles.frame, shape === \"circle\" && styles.circle)}\n style={{ aspectRatio: String(aspect) }}\n role=\"group\"\n aria-label={label}\n aria-describedby={hintId}\n tabIndex={0}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={onKeyDown}\n onWheel={(event) => {\n event.preventDefault();\n applyZoom(zoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));\n }}\n >\n {url && (\n <img\n ref={imageRef}\n src={url}\n alt=\"\"\n draggable={false}\n className={styles.image}\n style={{\n width: displayed.width || undefined,\n height: displayed.height || undefined,\n transform: `translate(${offset.x}px, ${offset.y}px)`,\n }}\n onLoad={(event) => {\n const element = event.currentTarget;\n // A decode can succeed and still report no intrinsic size —\n // an SVG without a viewBox is the common case. Accepting\n // that would enable the controls over an image the crop\n // maths can do nothing with.\n if (element.naturalWidth > 0 && element.naturalHeight > 0) {\n setNatural({\n width: element.naturalWidth,\n height: element.naturalHeight,\n });\n }\n }}\n />\n )}\n </div>\n\n <div className={styles.controls}>\n <input\n type=\"range\"\n className={styles.zoom}\n min={1}\n max={maxZoom}\n step={0.01}\n value={zoom}\n onChange={(event) => applyZoom(Number(event.target.value))}\n aria-label=\"Zoom\"\n disabled={!natural}\n />\n <button type=\"button\" className={styles.reset} onClick={reset} disabled={!natural}>\n Centralizar\n </button>\n </div>\n\n <p id={hintId} className={styles.hint}>\n Arraste para reposicionar. Setas movem, <kbd>+</kbd> e <kbd>−</kbd> dão zoom,{\" \"}\n <kbd>0</kbd> centraliza.\n </p>\n </div>\n );\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ImageCropper.js","names":[],"sources":["../../../src/components/ImageCropper/ImageCropper.tsx"],"sourcesContent":["import {\n type HTMLAttributes,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport {\n clampOffset,\n computeCropRect,\n coverScale,\n type Offset,\n outputSize,\n type Size,\n} from \"./crop-geometry\";\nimport styles from \"./ImageCropper.module.css\";\n\n/** Imperative handle for exporting the current crop. */\nexport interface ImageCropperHandle {\n /**\n * Render the current crop and resolve with it.\n *\n * Resolves `null` when the image has not loaded yet or the browser refuses to\n * encode — never throws, so a submit handler does not need a try/catch.\n */\n crop: () => Promise<Blob | null>;\n /** Recentre at zoom 1. */\n reset: () => void;\n}\n\nexport interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** The image to crop: a `File`/`Blob` from an input, or a URL. */\n src: File | Blob | string;\n /** Crop aspect ratio as `width / height`. Default `1` (square). */\n aspect?: number;\n /** Maximum zoom over the cover scale. Default `4`. */\n maxZoom?: number;\n /**\n * Cap on the longest edge of the exported image, in px.\n *\n * Without it the export keeps the source resolution, which is right for a\n * document scan and wasteful for an avatar.\n */\n maxSize?: number;\n /** Output MIME type. Default `\"image/png\"`. */\n outputType?: string;\n /** Output quality for lossy types, `0`–`1`. Default `0.92`. */\n outputQuality?: number;\n /** Overlay shape. `\"circle\"` for an avatar, `\"rect\"` for a document. Default `\"rect\"`. */\n shape?: \"rect\" | \"circle\";\n /** Called whenever the crop changes, e.g. to enable a submit button. */\n onCropChange?: (state: { zoom: number; offset: Offset }) => void;\n /** Accessible name for the crop area. */\n label?: string;\n ref?: React.Ref<ImageCropperHandle>;\n}\n\n/** Pixels moved per arrow-key press. */\nconst KEY_STEP = 12;\n\n/** Zoom added or removed per `+`/`-` press, and per wheel notch. */\nconst ZOOM_STEP = 0.15;\n\n/**\n * Crop an image to a fixed aspect ratio.\n *\n * The frame stays put and the image pans and zooms behind it — the model an avatar\n * or document-photo flow wants, where the output shape is decided by the app and\n * the user only chooses what lands inside it. (A free-form draggable rectangle is a\n * different component; this one cannot produce an off-ratio crop by construction.)\n *\n * The export reads the *natural* pixels through a canvas, so a 4000 px photo is not\n * silently downsampled to whatever the on-screen preview measured. The image is\n * also always clamped to cover the frame, so an export can never contain the empty\n * bands you get from panning past an edge.\n *\n * Works by pointer, wheel and keyboard: arrows pan, `+`/`-` zoom, `0` resets.\n *\n * @example\n * const cropper = useRef<ImageCropperHandle>(null);\n *\n * <ImageCropper ref={cropper} src={file} aspect={1} shape=\"circle\" maxSize={512} />\n * <Button onClick={async () => upload(await cropper.current?.crop())}>Salvar</Button>\n */\nexport function ImageCropper({\n src,\n aspect = 1,\n maxZoom = 4,\n maxSize,\n outputType = \"image/png\",\n outputQuality = 0.92,\n shape = \"rect\",\n onCropChange,\n label = \"Área de recorte\",\n className,\n ref,\n ...rest\n}: ImageCropperProps) {\n /**\n * Id for the shortcut hint.\n *\n * Generated rather than derived from `label`: a label with spaces would produce\n * an id with spaces, and `aria-describedby` splits on whitespace — so the\n * description would silently never associate with the frame.\n */\n const hintId = `${useId()}-hint`;\n\n const frameRef = useRef<HTMLDivElement>(null);\n const imageRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<{\n pointerId: number;\n startX: number;\n startY: number;\n from: Offset;\n } | null>(null);\n\n const [url, setUrl] = useState<string | null>(null);\n const [natural, setNatural] = useState<Size | null>(null);\n const [frame, setFrame] = useState<Size>({ width: 0, height: 0 });\n const [zoom, setZoom] = useState(1);\n const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });\n\n /**\n * Resolve `src` to a displayable URL.\n *\n * A `File`/`Blob` needs `createObjectURL`, and the URL must be revoked when the\n * source changes or the component unmounts — otherwise every re-pick of a photo\n * leaks the previous one for the lifetime of the document.\n */\n useEffect(() => {\n if (typeof src === \"string\") {\n setUrl(src);\n return;\n }\n const objectUrl = URL.createObjectURL(src);\n setUrl(objectUrl);\n return () => URL.revokeObjectURL(objectUrl);\n }, [src]);\n\n // A new source invalidates the previous framing.\n useEffect(() => {\n setNatural(null);\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n }, [url]);\n\n /** Track the frame's rendered size — the crop math is all relative to it. */\n useEffect(() => {\n const element = frameRef.current;\n if (!element) return;\n const measure = () =>\n setFrame({ width: element.clientWidth, height: element.clientHeight });\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n const scale = natural ? coverScale(natural, frame) * zoom : 0;\n\n /**\n * On-screen image size.\n *\n * Memoized because the pan callback depends on it: a fresh object every render\n * would rebuild that callback every render, and the pointer handlers close over\n * it during a drag.\n */\n const displayed = useMemo<Size>(\n () =>\n natural\n ? { width: natural.width * scale, height: natural.height * scale }\n : { width: 0, height: 0 },\n [natural, scale],\n );\n\n /** Apply a pan, clamped so the frame stays covered. */\n const pan = useCallback(\n (next: Offset) => {\n setOffset((current) => {\n const clamped = clampOffset(next, displayed, frame);\n if (clamped.x === current.x && clamped.y === current.y) return current;\n onCropChange?.({ zoom, offset: clamped });\n return clamped;\n });\n },\n [displayed, frame, onCropChange, zoom],\n );\n\n /**\n * Apply a zoom, then re-clamp the offset.\n *\n * Re-clamping is not optional: zooming *out* shrinks the image, so an offset\n * that was legal a moment ago can now expose background.\n */\n const applyZoom = useCallback(\n (next: number) => {\n const clampedZoom = Math.min(maxZoom, Math.max(1, next));\n setZoom(clampedZoom);\n if (!natural) return;\n const nextScale = coverScale(natural, frame) * clampedZoom;\n const nextDisplayed = {\n width: natural.width * nextScale,\n height: natural.height * nextScale,\n };\n setOffset((current) => {\n const clamped = clampOffset(current, nextDisplayed, frame);\n onCropChange?.({ zoom: clampedZoom, offset: clamped });\n return clamped;\n });\n },\n [frame, maxZoom, natural, onCropChange],\n );\n\n const reset = useCallback(() => {\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n onCropChange?.({ zoom: 1, offset: { x: 0, y: 0 } });\n }, [onCropChange]);\n\n /**\n * Draw the current crop and encode it.\n *\n * Returns `null` rather than throwing on the paths a caller cannot do anything\n * about: no image yet, no 2D context, or an encoder that declined.\n */\n const crop = useCallback(async (): Promise<Blob | null> => {\n const image = imageRef.current;\n if (!image || !natural) return null;\n\n const rect = computeCropRect({ image: natural, frame, zoom, offset });\n if (rect.sWidth <= 0 || rect.sHeight <= 0) return null;\n\n const out = outputSize(rect, maxSize);\n const canvas = document.createElement(\"canvas\");\n canvas.width = out.width;\n canvas.height = out.height;\n const context = canvas.getContext(\"2d\");\n if (!context) return null;\n\n context.drawImage(\n image,\n rect.sx,\n rect.sy,\n rect.sWidth,\n rect.sHeight,\n 0,\n 0,\n out.width,\n out.height,\n );\n\n return new Promise((resolve) => {\n canvas.toBlob((blob) => resolve(blob), outputType, outputQuality);\n });\n }, [frame, maxSize, natural, offset, outputQuality, outputType, zoom]);\n\n useImperativeHandle(ref, () => ({ crop, reset }), [crop, reset]);\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (!natural) return;\n dragRef.current = {\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n from: offset,\n };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== event.pointerId) return;\n pan({\n x: drag.from.x + (event.clientX - drag.startX),\n y: drag.from.y + (event.clientY - drag.startY),\n });\n };\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (dragRef.current?.pointerId !== event.pointerId) return;\n dragRef.current = null;\n };\n\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {\n const step = event.shiftKey ? KEY_STEP * 4 : KEY_STEP;\n const moves: Record<string, Offset> = {\n ArrowLeft: { x: -step, y: 0 },\n ArrowRight: { x: step, y: 0 },\n ArrowUp: { x: 0, y: -step },\n ArrowDown: { x: 0, y: step },\n };\n const move = moves[event.key];\n if (move) {\n event.preventDefault();\n pan({ x: offset.x + move.x, y: offset.y + move.y });\n return;\n }\n if (event.key === \"+\" || event.key === \"=\") {\n event.preventDefault();\n applyZoom(zoom + ZOOM_STEP);\n } else if (event.key === \"-\" || event.key === \"_\") {\n event.preventDefault();\n applyZoom(zoom - ZOOM_STEP);\n } else if (event.key === \"0\") {\n event.preventDefault();\n reset();\n }\n };\n\n return (\n <div className={cn(styles.wrapper, className)} {...rest}>\n <div\n ref={frameRef}\n className={cn(styles.frame, shape === \"circle\" && styles.circle)}\n style={{ aspectRatio: String(aspect) }}\n role=\"group\"\n aria-label={label}\n aria-describedby={hintId}\n tabIndex={0}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={onKeyDown}\n onWheel={(event) => {\n event.preventDefault();\n applyZoom(zoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));\n }}\n >\n {url && (\n <img\n ref={imageRef}\n src={url}\n alt=\"\"\n draggable={false}\n className={styles.image}\n style={{\n width: displayed.width || undefined,\n height: displayed.height || undefined,\n transform: `translate(${offset.x}px, ${offset.y}px)`,\n }}\n onLoad={(event) => {\n const element = event.currentTarget;\n // A decode can succeed and still report no intrinsic size —\n // an SVG without a viewBox is the common case. Accepting\n // that would enable the controls over an image the crop\n // maths can do nothing with.\n if (element.naturalWidth > 0 && element.naturalHeight > 0) {\n setNatural({\n width: element.naturalWidth,\n height: element.naturalHeight,\n });\n }\n }}\n />\n )}\n </div>\n\n <div className={styles.controls}>\n <input\n type=\"range\"\n className={styles.zoom}\n min={1}\n max={maxZoom}\n step={0.01}\n value={zoom}\n onChange={(event) => applyZoom(Number(event.target.value))}\n aria-label=\"Zoom\"\n disabled={!natural}\n />\n <button type=\"button\" className={styles.reset} onClick={reset} disabled={!natural}>\n Centralizar\n </button>\n </div>\n\n <p id={hintId} className={styles.hint}>\n Arraste para reposicionar. Setas movem, <kbd>+</kbd> e <kbd>−</kbd> dão zoom,{\" \"}\n <kbd>0</kbd> centraliza.\n </p>\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ImageCropper.js","names":[],"sources":["../../../src/components/ImageCropper/ImageCropper.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — pointer drag, wheel\n * zoom, aspect clamping and canvas export share one piece of geometry state, and the\n * props are the two halves of that: the frame (aspect, shape, maxZoom, label) and\n * the export (maxSize, outputType, outputQuality, onCropChange, ref). Threading the\n * geometry through props would duplicate the clamp maths.\n */\nimport {\n type HTMLAttributes,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport {\n clampOffset,\n computeCropRect,\n coverScale,\n type Offset,\n outputSize,\n type Size,\n} from \"./crop-geometry\";\nimport styles from \"./ImageCropper.module.css\";\n\n/** Imperative handle for exporting the current crop. */\nexport interface ImageCropperHandle {\n /**\n * Render the current crop and resolve with it.\n *\n * Resolves `null` when the image has not loaded yet or the browser refuses to\n * encode — never throws, so a submit handler does not need a try/catch.\n */\n crop: () => Promise<Blob | null>;\n /** Recentre at zoom 1. */\n reset: () => void;\n}\n\nexport interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** The image to crop: a `File`/`Blob` from an input, or a URL. */\n src: File | Blob | string;\n /** Crop aspect ratio as `width / height`. Default `1` (square). */\n aspect?: number;\n /** Maximum zoom over the cover scale. Default `4`. */\n maxZoom?: number;\n /**\n * Cap on the longest edge of the exported image, in px.\n *\n * Without it the export keeps the source resolution, which is right for a\n * document scan and wasteful for an avatar.\n */\n maxSize?: number;\n /** Output MIME type. Default `\"image/png\"`. */\n outputType?: string;\n /** Output quality for lossy types, `0`–`1`. Default `0.92`. */\n outputQuality?: number;\n /** Overlay shape. `\"circle\"` for an avatar, `\"rect\"` for a document. Default `\"rect\"`. */\n shape?: \"rect\" | \"circle\";\n /** Called whenever the crop changes, e.g. to enable a submit button. */\n onCropChange?: (state: { zoom: number; offset: Offset }) => void;\n /** Accessible name for the crop area. */\n label?: string;\n ref?: React.Ref<ImageCropperHandle>;\n}\n\n/** Pixels moved per arrow-key press. */\nconst KEY_STEP = 12;\n\n/** Zoom added or removed per `+`/`-` press, and per wheel notch. */\nconst ZOOM_STEP = 0.15;\n\n/**\n * Crop an image to a fixed aspect ratio.\n *\n * The frame stays put and the image pans and zooms behind it — the model an avatar\n * or document-photo flow wants, where the output shape is decided by the app and\n * the user only chooses what lands inside it. (A free-form draggable rectangle is a\n * different component; this one cannot produce an off-ratio crop by construction.)\n *\n * The export reads the *natural* pixels through a canvas, so a 4000 px photo is not\n * silently downsampled to whatever the on-screen preview measured. The image is\n * also always clamped to cover the frame, so an export can never contain the empty\n * bands you get from panning past an edge.\n *\n * Works by pointer, wheel and keyboard: arrows pan, `+`/`-` zoom, `0` resets.\n *\n * @example\n * const cropper = useRef<ImageCropperHandle>(null);\n *\n * <ImageCropper ref={cropper} src={file} aspect={1} shape=\"circle\" maxSize={512} />\n * <Button onClick={async () => upload(await cropper.current?.crop())}>Salvar</Button>\n */\nexport function ImageCropper({\n src,\n aspect = 1,\n maxZoom = 4,\n maxSize,\n outputType = \"image/png\",\n outputQuality = 0.92,\n shape = \"rect\",\n onCropChange,\n label = \"Área de recorte\",\n className,\n ref,\n ...rest\n}: ImageCropperProps) {\n /**\n * Id for the shortcut hint.\n *\n * Generated rather than derived from `label`: a label with spaces would produce\n * an id with spaces, and `aria-describedby` splits on whitespace — so the\n * description would silently never associate with the frame.\n */\n const hintId = `${useId()}-hint`;\n\n const frameRef = useRef<HTMLDivElement>(null);\n const imageRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<{\n pointerId: number;\n startX: number;\n startY: number;\n from: Offset;\n } | null>(null);\n\n const [url, setUrl] = useState<string | null>(null);\n const [natural, setNatural] = useState<Size | null>(null);\n const [frame, setFrame] = useState<Size>({ width: 0, height: 0 });\n const [zoom, setZoom] = useState(1);\n const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });\n\n /**\n * Resolve `src` to a displayable URL.\n *\n * A `File`/`Blob` needs `createObjectURL`, and the URL must be revoked when the\n * source changes or the component unmounts — otherwise every re-pick of a photo\n * leaks the previous one for the lifetime of the document.\n */\n useEffect(() => {\n if (typeof src === \"string\") {\n setUrl(src);\n return;\n }\n const objectUrl = URL.createObjectURL(src);\n setUrl(objectUrl);\n return () => URL.revokeObjectURL(objectUrl);\n }, [src]);\n\n // A new source invalidates the previous framing.\n useEffect(() => {\n setNatural(null);\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n }, [url]);\n\n /** Track the frame's rendered size — the crop math is all relative to it. */\n useEffect(() => {\n const element = frameRef.current;\n if (!element) return;\n const measure = () =>\n setFrame({ width: element.clientWidth, height: element.clientHeight });\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n\n const scale = natural ? coverScale(natural, frame) * zoom : 0;\n\n /**\n * On-screen image size.\n *\n * Memoized because the pan callback depends on it: a fresh object every render\n * would rebuild that callback every render, and the pointer handlers close over\n * it during a drag.\n */\n const displayed = useMemo<Size>(\n () =>\n natural\n ? { width: natural.width * scale, height: natural.height * scale }\n : { width: 0, height: 0 },\n [natural, scale],\n );\n\n /** Apply a pan, clamped so the frame stays covered. */\n const pan = useCallback(\n (next: Offset) => {\n setOffset((current) => {\n const clamped = clampOffset(next, displayed, frame);\n if (clamped.x === current.x && clamped.y === current.y) return current;\n onCropChange?.({ zoom, offset: clamped });\n return clamped;\n });\n },\n [displayed, frame, onCropChange, zoom],\n );\n\n /**\n * Apply a zoom, then re-clamp the offset.\n *\n * Re-clamping is not optional: zooming *out* shrinks the image, so an offset\n * that was legal a moment ago can now expose background.\n */\n const applyZoom = useCallback(\n (next: number) => {\n const clampedZoom = Math.min(maxZoom, Math.max(1, next));\n setZoom(clampedZoom);\n if (!natural) return;\n const nextScale = coverScale(natural, frame) * clampedZoom;\n const nextDisplayed = {\n width: natural.width * nextScale,\n height: natural.height * nextScale,\n };\n setOffset((current) => {\n const clamped = clampOffset(current, nextDisplayed, frame);\n onCropChange?.({ zoom: clampedZoom, offset: clamped });\n return clamped;\n });\n },\n [frame, maxZoom, natural, onCropChange],\n );\n\n const reset = useCallback(() => {\n setZoom(1);\n setOffset({ x: 0, y: 0 });\n onCropChange?.({ zoom: 1, offset: { x: 0, y: 0 } });\n }, [onCropChange]);\n\n /**\n * Draw the current crop and encode it.\n *\n * Returns `null` rather than throwing on the paths a caller cannot do anything\n * about: no image yet, no 2D context, or an encoder that declined.\n */\n const crop = useCallback(async (): Promise<Blob | null> => {\n const image = imageRef.current;\n if (!image || !natural) return null;\n\n const rect = computeCropRect({ image: natural, frame, zoom, offset });\n if (rect.sWidth <= 0 || rect.sHeight <= 0) return null;\n\n const out = outputSize(rect, maxSize);\n const canvas = document.createElement(\"canvas\");\n canvas.width = out.width;\n canvas.height = out.height;\n const context = canvas.getContext(\"2d\");\n if (!context) return null;\n\n context.drawImage(\n image,\n rect.sx,\n rect.sy,\n rect.sWidth,\n rect.sHeight,\n 0,\n 0,\n out.width,\n out.height,\n );\n\n return new Promise((resolve) => {\n canvas.toBlob((blob) => resolve(blob), outputType, outputQuality);\n });\n }, [frame, maxSize, natural, offset, outputQuality, outputType, zoom]);\n\n useImperativeHandle(ref, () => ({ crop, reset }), [crop, reset]);\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (!natural) return;\n dragRef.current = {\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n from: offset,\n };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== event.pointerId) return;\n pan({\n x: drag.from.x + (event.clientX - drag.startX),\n y: drag.from.y + (event.clientY - drag.startY),\n });\n };\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {\n if (dragRef.current?.pointerId !== event.pointerId) return;\n dragRef.current = null;\n };\n\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {\n const step = event.shiftKey ? KEY_STEP * 4 : KEY_STEP;\n const moves: Record<string, Offset> = {\n ArrowLeft: { x: -step, y: 0 },\n ArrowRight: { x: step, y: 0 },\n ArrowUp: { x: 0, y: -step },\n ArrowDown: { x: 0, y: step },\n };\n const move = moves[event.key];\n if (move) {\n event.preventDefault();\n pan({ x: offset.x + move.x, y: offset.y + move.y });\n return;\n }\n if (event.key === \"+\" || event.key === \"=\") {\n event.preventDefault();\n applyZoom(zoom + ZOOM_STEP);\n } else if (event.key === \"-\" || event.key === \"_\") {\n event.preventDefault();\n applyZoom(zoom - ZOOM_STEP);\n } else if (event.key === \"0\") {\n event.preventDefault();\n reset();\n }\n };\n\n return (\n <div className={cn(styles.wrapper, className)} {...rest}>\n <div\n ref={frameRef}\n className={cn(styles.frame, shape === \"circle\" && styles.circle)}\n style={{ aspectRatio: String(aspect) }}\n role=\"group\"\n aria-label={label}\n aria-describedby={hintId}\n tabIndex={0}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={onKeyDown}\n onWheel={(event) => {\n event.preventDefault();\n applyZoom(zoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));\n }}\n >\n {url && (\n <img\n ref={imageRef}\n src={url}\n alt=\"\"\n draggable={false}\n className={styles.image}\n style={{\n width: displayed.width || undefined,\n height: displayed.height || undefined,\n transform: `translate(${offset.x}px, ${offset.y}px)`,\n }}\n onLoad={(event) => {\n const element = event.currentTarget;\n // A decode can succeed and still report no intrinsic size —\n // an SVG without a viewBox is the common case. Accepting\n // that would enable the controls over an image the crop\n // maths can do nothing with.\n if (element.naturalWidth > 0 && element.naturalHeight > 0) {\n setNatural({\n width: element.naturalWidth,\n height: element.naturalHeight,\n });\n }\n }}\n />\n )}\n </div>\n\n <div className={styles.controls}>\n <input\n type=\"range\"\n className={styles.zoom}\n min={1}\n max={maxZoom}\n step={0.01}\n value={zoom}\n onChange={(event) => applyZoom(Number(event.target.value))}\n aria-label=\"Zoom\"\n disabled={!natural}\n />\n <button type=\"button\" className={styles.reset} onClick={reset} disabled={!natural}>\n Centralizar\n </button>\n </div>\n\n <p id={hintId} className={styles.hint}>\n Arraste para reposicionar. Setas movem, <kbd>+</kbd> e <kbd>−</kbd> dão zoom,{\" \"}\n <kbd>0</kbd> centraliza.\n </p>\n </div>\n );\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"InstallBanner.cjs","names":[],"sources":["../../../src/components/InstallBanner/InstallBanner.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"@/components/Button\";\nimport { useBeforeInstallPrompt } from \"@/hooks/use-before-install-prompt\";\nimport type { InstallOutcome } from \"@/components/InstallButton\";\nimport styles from \"./InstallBanner.module.css\";\n\nexport interface InstallBannerProps {\n /** Headline. Default `\"Instale o app\"`. */\n title?: ReactNode;\n /** Supporting copy under the title. */\n description?: ReactNode;\n /** Install button label. Default `\"Instalar\"`. */\n installLabel?: string;\n /** Accessible label for the dismiss button. Default `\"Dispensar\"`. */\n dismissLabel?: string;\n /** Optional leading icon. */\n icon?: ReactNode;\n /**\n * `localStorage` key used to remember dismissal across reloads. Omit to\n * make dismissal last only for the current session (component state).\n */\n storageKey?: string;\n /** Called with the user's choice after the install prompt resolves. */\n onResult?: (outcome: InstallOutcome) => void;\n className?: string;\n}\n\nfunction readDismissed(storageKey?: string): boolean {\n if (!storageKey || typeof window === \"undefined\") return false;\n try {\n return window.localStorage.getItem(storageKey) === \"1\";\n } catch {\n return false;\n }\n}\n\n/**\n * Dismissible bottom banner that invites the user to install the PWA. Wired to\n * {@link useBeforeInstallPrompt}: it renders only when the browser captured an\n * install prompt and the app is not already running standalone — so on\n * platforms that never fire `beforeinstallprompt` (e.g. iOS Safari) it stays\n * hidden and you can surface manual instructions elsewhere.\n *\n * @example\n * <InstallBanner\n * title=\"Instale o FAMACHApp\"\n * description=\"Acesso offline e atalho na tela inicial.\"\n * storageKey=\"famacha:install-dismissed\"\n * />\n */\nexport function InstallBanner({\n title = \"Instale o app\",\n description,\n installLabel = \"Instalar\",\n dismissLabel = \"Dispensar\",\n icon,\n storageKey,\n onResult,\n className,\n}: InstallBannerProps) {\n const { installable, installed, isStandalone, prompt } = useBeforeInstallPrompt();\n const [dismissed, setDismissed] = useState<boolean>(() => readDismissed(storageKey));\n\n if (!installable || installed || isStandalone || dismissed) return null;\n\n const dismiss = (): void => {\n setDismissed(true);\n if (storageKey && typeof window !== \"undefined\") {\n try {\n window.localStorage.setItem(storageKey, \"1\");\n } catch {\n /* ignore quota errors */\n }\n }\n };\n\n return (\n <div className={cn(styles.banner, className)} role=\"region\" aria-label={String(title)}>\n {icon && <span className={styles.icon}>{icon}</span>}\n <div className={styles.body}>\n <p className={styles.title}>{title}</p>\n {description && <p className={styles.description}>{description}</p>}\n </div>\n <Button\n size=\"sm\"\n onClick={async () => {\n onResult?.(await prompt());\n }}\n >\n {installLabel}\n </Button>\n <button\n type=\"button\"\n className={styles.close}\n aria-label={dismissLabel}\n onClick={dismiss}\n >\n <X size={18} aria-hidden />\n </button>\n </div>\n );\n}\n"],"mappings":"uPA8BA,SAAS,EAAc,EAA8B,CACjD,GAAI,CAAC,GAAc,OAAO,OAAW,IAAa,MAAO,GACzD,GAAI,CACA,OAAO,OAAO,aAAa,QAAQ,CAAU,IAAM,GACvD,MAAQ,CACJ,MAAO,EACX,CACJ,CAgBA,SAAgB,EAAc,CAC1B,QAAQ,gBACR,cACA,eAAe,WACf,eAAe,YACf,OACA,aACA,WACA,aACmB,CACnB,GAAM,CAAE,cAAa,YAAW,eAAc,UAAW,EAAA,uBAAuB,EAC1E,CAAC,EAAW,IAAA,EAAA,EAAA,SAAA,KAAwC,EAAc,CAAU,CAAC,EAenF,MAbI,CAAC,GAAe,GAAa,GAAgB,EAAkB,MAc/D,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,GAAG,EAAA,QAAO,OAAQ,CAAS,EAAG,KAAK,SAAS,aAAY,OAAO,CAAK,WAApF,CACK,IAAQ,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,cAAO,CAAW,CAAA,GACnD,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,cAAvB,EACI,EAAA,EAAA,IAAA,CAAC,IAAD,CAAG,UAAW,EAAA,QAAO,eAAQ,CAAS,CAAA,EACrC,IAAe,EAAA,EAAA,IAAA,CAAC,IAAD,CAAG,UAAW,EAAA,QAAO,qBAAc,CAAe,CAAA,CACjE,KACL,EAAA,EAAA,IAAA,CAAC,EAAA,OAAD,CACI,KAAK,KACL,QAAS,SAAY,CACjB,IAAW,MAAM,EAAO,CAAC,CAC7B,WAEC,CACG,CAAA,GACR,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,UAAW,EAAA,QAAO,MAClB,aAAY,EACZ,YA9BgB,CAExB,GADA,EAAa,EAAI,EACb,GAAc,OAAO,OAAW,IAChC,GAAI,CACA,OAAO,aAAa,QAAQ,EAAY,GAAG,CAC/C,MAAQ,CAER,CAER,YAuBY,EAAA,EAAA,IAAA,CAAC,EAAA,EAAD,CAAG,KAAM,GAAI,cAAA,EAAa,CAAA,CACtB,CAAA,CACP,GAEb"}
1
+ {"version":3,"file":"InstallBanner.cjs","names":[],"sources":["../../../src/components/InstallBanner/InstallBanner.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — title, description, installLabel and dismissLabel\n * are four strings the app has to write, icon and className the look, storageKey the\n * dismissal memory and onResult the outcome. An install prompt with SDK-authored\n * copy is the one thing nobody ships.\n */\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"@/components/Button\";\nimport { useBeforeInstallPrompt } from \"@/hooks/use-before-install-prompt\";\nimport type { InstallOutcome } from \"@/components/InstallButton\";\nimport styles from \"./InstallBanner.module.css\";\n\nexport interface InstallBannerProps {\n /** Headline. Default `\"Instale o app\"`. */\n title?: ReactNode;\n /** Supporting copy under the title. */\n description?: ReactNode;\n /** Install button label. Default `\"Instalar\"`. */\n installLabel?: string;\n /** Accessible label for the dismiss button. Default `\"Dispensar\"`. */\n dismissLabel?: string;\n /** Optional leading icon. */\n icon?: ReactNode;\n /**\n * `localStorage` key used to remember dismissal across reloads. Omit to\n * make dismissal last only for the current session (component state).\n */\n storageKey?: string;\n /** Called with the user's choice after the install prompt resolves. */\n onResult?: (outcome: InstallOutcome) => void;\n className?: string;\n}\n\nfunction readDismissed(storageKey?: string): boolean {\n if (!storageKey || typeof window === \"undefined\") return false;\n try {\n return window.localStorage.getItem(storageKey) === \"1\";\n } catch {\n return false;\n }\n}\n\n/**\n * Dismissible bottom banner that invites the user to install the PWA. Wired to\n * {@link useBeforeInstallPrompt}: it renders only when the browser captured an\n * install prompt and the app is not already running standalone — so on\n * platforms that never fire `beforeinstallprompt` (e.g. iOS Safari) it stays\n * hidden and you can surface manual instructions elsewhere.\n *\n * @tempest-limits empty-catch — persisting the dismissal is a courtesy, not the\n * feature. When `localStorage` refuses the write (quota, private mode) the banner\n * still hides for this session; the worst case is that it comes back next visit,\n * which beats an error thrown out of a click handler that only closed a banner.\n *\n * @example\n * <InstallBanner\n * title=\"Instale o FAMACHApp\"\n * description=\"Acesso offline e atalho na tela inicial.\"\n * storageKey=\"famacha:install-dismissed\"\n * />\n */\nexport function InstallBanner({\n title = \"Instale o app\",\n description,\n installLabel = \"Instalar\",\n dismissLabel = \"Dispensar\",\n icon,\n storageKey,\n onResult,\n className,\n}: InstallBannerProps) {\n const { installable, installed, isStandalone, prompt } = useBeforeInstallPrompt();\n const [dismissed, setDismissed] = useState<boolean>(() => readDismissed(storageKey));\n\n if (!installable || installed || isStandalone || dismissed) return null;\n\n const dismiss = (): void => {\n setDismissed(true);\n if (storageKey && typeof window !== \"undefined\") {\n try {\n window.localStorage.setItem(storageKey, \"1\");\n } catch {\n /* empty */\n }\n }\n };\n\n return (\n <div className={cn(styles.banner, className)} role=\"region\" aria-label={String(title)}>\n {icon && <span className={styles.icon}>{icon}</span>}\n <div className={styles.body}>\n <p className={styles.title}>{title}</p>\n {description && <p className={styles.description}>{description}</p>}\n </div>\n <Button\n size=\"sm\"\n onClick={async () => {\n onResult?.(await prompt());\n }}\n >\n {installLabel}\n </Button>\n <button\n type=\"button\"\n className={styles.close}\n aria-label={dismissLabel}\n onClick={dismiss}\n >\n <X size={18} aria-hidden />\n </button>\n </div>\n );\n}\n"],"mappings":"uPAoCA,SAAS,EAAc,EAA8B,CACjD,GAAI,CAAC,GAAc,OAAO,OAAW,IAAa,MAAO,GACzD,GAAI,CACA,OAAO,OAAO,aAAa,QAAQ,CAAU,IAAM,GACvD,MAAQ,CACJ,MAAO,EACX,CACJ,CAqBA,SAAgB,EAAc,CAC1B,QAAQ,gBACR,cACA,eAAe,WACf,eAAe,YACf,OACA,aACA,WACA,aACmB,CACnB,GAAM,CAAE,cAAa,YAAW,eAAc,UAAW,EAAA,uBAAuB,EAC1E,CAAC,EAAW,IAAA,EAAA,EAAA,SAAA,KAAwC,EAAc,CAAU,CAAC,EAenF,MAbI,CAAC,GAAe,GAAa,GAAgB,EAAkB,MAc/D,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,GAAG,EAAA,QAAO,OAAQ,CAAS,EAAG,KAAK,SAAS,aAAY,OAAO,CAAK,WAApF,CACK,IAAQ,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,cAAO,CAAW,CAAA,GACnD,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,cAAvB,EACI,EAAA,EAAA,IAAA,CAAC,IAAD,CAAG,UAAW,EAAA,QAAO,eAAQ,CAAS,CAAA,EACrC,IAAe,EAAA,EAAA,IAAA,CAAC,IAAD,CAAG,UAAW,EAAA,QAAO,qBAAc,CAAe,CAAA,CACjE,KACL,EAAA,EAAA,IAAA,CAAC,EAAA,OAAD,CACI,KAAK,KACL,QAAS,SAAY,CACjB,IAAW,MAAM,EAAO,CAAC,CAC7B,WAEC,CACG,CAAA,GACR,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,UAAW,EAAA,QAAO,MAClB,aAAY,EACZ,YA9BgB,CAExB,GADA,EAAa,EAAI,EACb,GAAc,OAAO,OAAW,IAChC,GAAI,CACA,OAAO,aAAa,QAAQ,EAAY,GAAG,CAC/C,MAAQ,CAER,CAER,YAuBY,EAAA,EAAA,IAAA,CAAC,EAAA,EAAD,CAAG,KAAM,GAAI,cAAA,EAAa,CAAA,CACtB,CAAA,CACP,GAEb"}
@@ -1 +1 @@
1
- {"version":3,"file":"InstallBanner.js","names":[],"sources":["../../../src/components/InstallBanner/InstallBanner.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"@/components/Button\";\nimport { useBeforeInstallPrompt } from \"@/hooks/use-before-install-prompt\";\nimport type { InstallOutcome } from \"@/components/InstallButton\";\nimport styles from \"./InstallBanner.module.css\";\n\nexport interface InstallBannerProps {\n /** Headline. Default `\"Instale o app\"`. */\n title?: ReactNode;\n /** Supporting copy under the title. */\n description?: ReactNode;\n /** Install button label. Default `\"Instalar\"`. */\n installLabel?: string;\n /** Accessible label for the dismiss button. Default `\"Dispensar\"`. */\n dismissLabel?: string;\n /** Optional leading icon. */\n icon?: ReactNode;\n /**\n * `localStorage` key used to remember dismissal across reloads. Omit to\n * make dismissal last only for the current session (component state).\n */\n storageKey?: string;\n /** Called with the user's choice after the install prompt resolves. */\n onResult?: (outcome: InstallOutcome) => void;\n className?: string;\n}\n\nfunction readDismissed(storageKey?: string): boolean {\n if (!storageKey || typeof window === \"undefined\") return false;\n try {\n return window.localStorage.getItem(storageKey) === \"1\";\n } catch {\n return false;\n }\n}\n\n/**\n * Dismissible bottom banner that invites the user to install the PWA. Wired to\n * {@link useBeforeInstallPrompt}: it renders only when the browser captured an\n * install prompt and the app is not already running standalone — so on\n * platforms that never fire `beforeinstallprompt` (e.g. iOS Safari) it stays\n * hidden and you can surface manual instructions elsewhere.\n *\n * @example\n * <InstallBanner\n * title=\"Instale o FAMACHApp\"\n * description=\"Acesso offline e atalho na tela inicial.\"\n * storageKey=\"famacha:install-dismissed\"\n * />\n */\nexport function InstallBanner({\n title = \"Instale o app\",\n description,\n installLabel = \"Instalar\",\n dismissLabel = \"Dispensar\",\n icon,\n storageKey,\n onResult,\n className,\n}: InstallBannerProps) {\n const { installable, installed, isStandalone, prompt } = useBeforeInstallPrompt();\n const [dismissed, setDismissed] = useState<boolean>(() => readDismissed(storageKey));\n\n if (!installable || installed || isStandalone || dismissed) return null;\n\n const dismiss = (): void => {\n setDismissed(true);\n if (storageKey && typeof window !== \"undefined\") {\n try {\n window.localStorage.setItem(storageKey, \"1\");\n } catch {\n /* ignore quota errors */\n }\n }\n };\n\n return (\n <div className={cn(styles.banner, className)} role=\"region\" aria-label={String(title)}>\n {icon && <span className={styles.icon}>{icon}</span>}\n <div className={styles.body}>\n <p className={styles.title}>{title}</p>\n {description && <p className={styles.description}>{description}</p>}\n </div>\n <Button\n size=\"sm\"\n onClick={async () => {\n onResult?.(await prompt());\n }}\n >\n {installLabel}\n </Button>\n <button\n type=\"button\"\n className={styles.close}\n aria-label={dismissLabel}\n onClick={dismiss}\n >\n <X size={18} aria-hidden />\n </button>\n </div>\n );\n}\n"],"mappings":";;;;;;;;AA8BA,SAAS,EAAc,GAA8B;CACjD,IAAI,CAAC,KAAc,OAAO,SAAW,KAAa,OAAO;CACzD,IAAI;EACA,OAAO,OAAO,aAAa,QAAQ,CAAU,MAAM;CACvD,QAAQ;EACJ,OAAO;CACX;AACJ;AAgBA,SAAgB,EAAc,EAC1B,WAAQ,iBACR,gBACA,kBAAe,YACf,kBAAe,aACf,SACA,eACA,aACA,gBACmB;CACnB,IAAM,EAAE,gBAAa,cAAW,iBAAc,cAAW,EAAuB,GAC1E,CAAC,GAAW,KAAgB,QAAwB,EAAc,CAAU,CAAC;CAenF,OAbI,CAAC,KAAe,KAAa,KAAgB,IAAkB,OAc/D,kBAAC,OAAD;EAAK,WAAW,EAAG,EAAO,QAAQ,CAAS;EAAG,MAAK;EAAS,cAAY,OAAO,CAAK;YAApF;GACK,KAAQ,kBAAC,QAAD;IAAM,WAAW,EAAO;cAAO;GAAW,CAAA;GACnD,kBAAC,OAAD;IAAK,WAAW,EAAO;cAAvB,CACI,kBAAC,KAAD;KAAG,WAAW,EAAO;eAAQ;IAAS,CAAA,GACrC,KAAe,kBAAC,KAAD;KAAG,WAAW,EAAO;eAAc;IAAe,CAAA,CACjE;;GACL,kBAAC,GAAD;IACI,MAAK;IACL,SAAS,YAAY;KACjB,IAAW,MAAM,EAAO,CAAC;IAC7B;cAEC;GACG,CAAA;GACR,kBAAC,UAAD;IACI,MAAK;IACL,WAAW,EAAO;IAClB,cAAY;IACZ,eA9BgB;KAExB,IADA,EAAa,EAAI,GACb,KAAc,OAAO,SAAW,KAChC,IAAI;MACA,OAAO,aAAa,QAAQ,GAAY,GAAG;KAC/C,QAAQ,CAER;IAER;cAuBY,kBAAC,GAAD;KAAG,MAAM;KAAI,eAAA;IAAa,CAAA;GACtB,CAAA;EACP;;AAEb"}
1
+ {"version":3,"file":"InstallBanner.js","names":[],"sources":["../../../src/components/InstallBanner/InstallBanner.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — title, description, installLabel and dismissLabel\n * are four strings the app has to write, icon and className the look, storageKey the\n * dismissal memory and onResult the outcome. An install prompt with SDK-authored\n * copy is the one thing nobody ships.\n */\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"@/components/Button\";\nimport { useBeforeInstallPrompt } from \"@/hooks/use-before-install-prompt\";\nimport type { InstallOutcome } from \"@/components/InstallButton\";\nimport styles from \"./InstallBanner.module.css\";\n\nexport interface InstallBannerProps {\n /** Headline. Default `\"Instale o app\"`. */\n title?: ReactNode;\n /** Supporting copy under the title. */\n description?: ReactNode;\n /** Install button label. Default `\"Instalar\"`. */\n installLabel?: string;\n /** Accessible label for the dismiss button. Default `\"Dispensar\"`. */\n dismissLabel?: string;\n /** Optional leading icon. */\n icon?: ReactNode;\n /**\n * `localStorage` key used to remember dismissal across reloads. Omit to\n * make dismissal last only for the current session (component state).\n */\n storageKey?: string;\n /** Called with the user's choice after the install prompt resolves. */\n onResult?: (outcome: InstallOutcome) => void;\n className?: string;\n}\n\nfunction readDismissed(storageKey?: string): boolean {\n if (!storageKey || typeof window === \"undefined\") return false;\n try {\n return window.localStorage.getItem(storageKey) === \"1\";\n } catch {\n return false;\n }\n}\n\n/**\n * Dismissible bottom banner that invites the user to install the PWA. Wired to\n * {@link useBeforeInstallPrompt}: it renders only when the browser captured an\n * install prompt and the app is not already running standalone — so on\n * platforms that never fire `beforeinstallprompt` (e.g. iOS Safari) it stays\n * hidden and you can surface manual instructions elsewhere.\n *\n * @tempest-limits empty-catch — persisting the dismissal is a courtesy, not the\n * feature. When `localStorage` refuses the write (quota, private mode) the banner\n * still hides for this session; the worst case is that it comes back next visit,\n * which beats an error thrown out of a click handler that only closed a banner.\n *\n * @example\n * <InstallBanner\n * title=\"Instale o FAMACHApp\"\n * description=\"Acesso offline e atalho na tela inicial.\"\n * storageKey=\"famacha:install-dismissed\"\n * />\n */\nexport function InstallBanner({\n title = \"Instale o app\",\n description,\n installLabel = \"Instalar\",\n dismissLabel = \"Dispensar\",\n icon,\n storageKey,\n onResult,\n className,\n}: InstallBannerProps) {\n const { installable, installed, isStandalone, prompt } = useBeforeInstallPrompt();\n const [dismissed, setDismissed] = useState<boolean>(() => readDismissed(storageKey));\n\n if (!installable || installed || isStandalone || dismissed) return null;\n\n const dismiss = (): void => {\n setDismissed(true);\n if (storageKey && typeof window !== \"undefined\") {\n try {\n window.localStorage.setItem(storageKey, \"1\");\n } catch {\n /* empty */\n }\n }\n };\n\n return (\n <div className={cn(styles.banner, className)} role=\"region\" aria-label={String(title)}>\n {icon && <span className={styles.icon}>{icon}</span>}\n <div className={styles.body}>\n <p className={styles.title}>{title}</p>\n {description && <p className={styles.description}>{description}</p>}\n </div>\n <Button\n size=\"sm\"\n onClick={async () => {\n onResult?.(await prompt());\n }}\n >\n {installLabel}\n </Button>\n <button\n type=\"button\"\n className={styles.close}\n aria-label={dismissLabel}\n onClick={dismiss}\n >\n <X size={18} aria-hidden />\n </button>\n </div>\n );\n}\n"],"mappings":";;;;;;;;AAoCA,SAAS,EAAc,GAA8B;CACjD,IAAI,CAAC,KAAc,OAAO,SAAW,KAAa,OAAO;CACzD,IAAI;EACA,OAAO,OAAO,aAAa,QAAQ,CAAU,MAAM;CACvD,QAAQ;EACJ,OAAO;CACX;AACJ;AAqBA,SAAgB,EAAc,EAC1B,WAAQ,iBACR,gBACA,kBAAe,YACf,kBAAe,aACf,SACA,eACA,aACA,gBACmB;CACnB,IAAM,EAAE,gBAAa,cAAW,iBAAc,cAAW,EAAuB,GAC1E,CAAC,GAAW,KAAgB,QAAwB,EAAc,CAAU,CAAC;CAenF,OAbI,CAAC,KAAe,KAAa,KAAgB,IAAkB,OAc/D,kBAAC,OAAD;EAAK,WAAW,EAAG,EAAO,QAAQ,CAAS;EAAG,MAAK;EAAS,cAAY,OAAO,CAAK;YAApF;GACK,KAAQ,kBAAC,QAAD;IAAM,WAAW,EAAO;cAAO;GAAW,CAAA;GACnD,kBAAC,OAAD;IAAK,WAAW,EAAO;cAAvB,CACI,kBAAC,KAAD;KAAG,WAAW,EAAO;eAAQ;IAAS,CAAA,GACrC,KAAe,kBAAC,KAAD;KAAG,WAAW,EAAO;eAAc;IAAe,CAAA,CACjE;;GACL,kBAAC,GAAD;IACI,MAAK;IACL,SAAS,YAAY;KACjB,IAAW,MAAM,EAAO,CAAC;IAC7B;cAEC;GACG,CAAA;GACR,kBAAC,UAAD;IACI,MAAK;IACL,WAAW,EAAO;IAClB,cAAY;IACZ,eA9BgB;KAExB,IADA,EAAa,EAAI,GACb,KAAc,OAAO,SAAW,KAChC,IAAI;MACA,OAAO,aAAa,QAAQ,GAAY,GAAG;KAC/C,QAAQ,CAER;IAER;cAuBY,kBAAC,GAAD;KAAG,MAAM;KAAI,eAAA;IAAa,CAAA;GACtB,CAAA;EACP;;AAEb"}
@@ -1 +1 @@
1
- {"version":3,"file":"Kanban.cjs","names":[],"sources":["../../../src/components/Kanban/Kanban.tsx"],"sourcesContent":["/* eslint-disable react-hooks/refs -- `getItemProps` is called during render and its\n handlers close over `useSortable`'s mirror refs, so the rule reports the call site\n here. The refs are only written from event handlers and read from a window\n listener; the two reads that were genuinely render-time (the drag group in the\n rendered class/aria, and the group lookup inside `commit`) were fixed instead of\n silenced — see the hook's docstring. */\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useSortable } from \"@/hooks/use-sortable\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./Kanban.module.css\";\n\n/** One card on the board. */\nexport interface KanbanCard {\n /** Stable identifier, unique across the whole board. */\n id: string;\n /** Card body. A string renders as the card's title. */\n content: ReactNode;\n}\n\n/** One column of the board. */\nexport interface KanbanColumn {\n /** Stable identifier, used as the drop group. */\n id: string;\n /** Column heading. */\n title: ReactNode;\n /** Cards in display order. */\n cards: KanbanCard[];\n /**\n * Refuses drops into this column. The cards inside can still be picked up and\n * moved elsewhere — a \"done\" column that no longer accepts work, for instance.\n */\n locked?: boolean;\n}\n\n/** Where a card ended up. */\nexport interface KanbanMove {\n cardId: string;\n fromColumn: string;\n toColumn: string;\n /** Index within the destination column. */\n toIndex: number;\n}\n\nexport interface KanbanProps {\n columns: KanbanColumn[];\n /**\n * Called once per committed move — on pointer release or on a keyboard drop.\n * You own the data: apply the move and pass the new `columns` back.\n */\n onMove: (move: KanbanMove) => void;\n /** Renders a card. Defaults to its `content`. */\n renderCard?: (card: KanbanCard, column: KanbanColumn) => ReactNode;\n /** Text shown in a column with no cards. */\n emptyLabel?: ReactNode;\n /** Announced per card, so a screen reader user learns the keyboard contract. */\n cardRoleDescription?: string;\n /**\n * Accessible name of the board. A `listbox` without a name is unusable with a\n * screen reader, so this has a default rather than being optional in practice.\n */\n label?: string;\n /** Blocks all dragging. */\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Kanban board: columns of cards, reorderable within a column and movable across\n * columns, by pointer **or** keyboard.\n *\n * The drag machine is `useSortable` — the board does not reimplement it. Cards are\n * indexed globally across columns so a single sortable can span the whole board;\n * the component translates that flat index back into `{ column, index }` for\n * `onMove`, which is the shape an app actually needs.\n *\n * Nothing is mutated here: `onMove` reports the intent once and the parent applies\n * it. That keeps the board a controlled value and avoids a re-render per pointer\n * frame.\n *\n * ARIA shape: **one `listbox` per column**, named by the column title, containing\n * only `option` cards. A single board-wide listbox does not survive the markup a\n * board needs — `listbox` requires `option`/`group` children, and the column header\n * and card wrapper in between break that ownership. Per-column listboxes also read\n * better: a screen reader announces which column it is in and how many cards it\n * holds. The column header is a plain `div`, not a `<header>`, because outside a\n * sectioning element every `<header>` becomes a page-level banner landmark — with\n * three columns that is three duplicate banners. An **empty** column is not marked\n * as a listbox at all: a listbox with zero options fails `aria-required-children`,\n * and \"no cards\" is exactly zero options.\n *\n * Known limitation: a **keyboard** move can only target positions that hold a card,\n * because the move walks the flat index space of existing cards. Dropping into an\n * empty column works by pointer; by keyboard it does not. Moving a card to a column\n * that already has one card, then reordering, is the workaround until column-switch\n * keys land.\n *\n * @example\n * ```tsx\n * const [columns, setColumns] = useState<KanbanColumn[]>([\n * { id: \"todo\", title: \"A fazer\", cards: [{ id: \"1\", content: \"Corrigir login\" }] },\n * { id: \"doing\", title: \"Fazendo\", cards: [] },\n * { id: \"done\", title: \"Feito\", cards: [], locked: true },\n * ]);\n *\n * <Kanban\n * columns={columns}\n * onMove={({ cardId, fromColumn, toColumn, toIndex }) =>\n * setColumns((current) => applyKanbanMove(current, { cardId, fromColumn, toColumn, toIndex }))\n * }\n * />\n * ```\n */\nexport function Kanban({\n columns,\n onMove,\n renderCard,\n emptyLabel = \"Nenhum card\",\n cardRoleDescription = \"Card do quadro — Espaço pega, setas movem, Espaço solta\",\n label = \"Quadro\",\n disabled = false,\n className,\n}: KanbanProps) {\n /**\n * Flat view of every card, in column order.\n *\n * `useSortable` works on a single index space, so the board flattens itself and\n * keeps the mapping back to `{ column, index }` here — the only place that has\n * to know both shapes.\n */\n const flat = useMemo(\n () =>\n columns.flatMap((column, columnIndex) =>\n column.cards.map((card, cardIndex) => ({ card, column, columnIndex, cardIndex })),\n ),\n [columns],\n );\n\n const sortable = useSortable({\n itemCount: flat.length,\n disabled,\n roleDescription: cardRoleDescription,\n onReorder: ({ from, to, fromGroup, toGroup }) => {\n const source = flat[from];\n if (!source) return;\n\n const destinationId = toGroup ?? fromGroup ?? source.column.id;\n const destination = columns.find((column) => column.id === destinationId);\n if (!destination || destination.locked) return;\n\n const target = flat[to];\n const toIndex =\n target && target.column.id === destinationId\n ? target.cardIndex\n : destination.cards.length;\n\n onMove({\n cardId: source.card.id,\n fromColumn: source.column.id,\n toColumn: destinationId,\n toIndex,\n });\n },\n });\n\n /**\n * First flat index of each column.\n *\n * Derived instead of counted with a mutable `flatIndex++` during render: a\n * render-scoped counter mutated inside the map is exactly the pattern the React\n * Compiler rules reject, and for good reason — a partial or replayed render\n * would resume from a half-advanced counter.\n */\n const columnOffsets = useMemo(() => {\n const offsets: number[] = [];\n let start = 0;\n for (const column of columns) {\n offsets.push(start);\n start += column.cards.length;\n }\n return offsets;\n }, [columns]);\n\n return (\n <div className={cn(styles.board, className)} aria-label={label} ref={sortable.setContainer}>\n {columns.map((column, columnIndex) => {\n const isDropTarget = sortable.overGroup === column.id && !column.locked;\n return (\n <div\n key={column.id}\n className={cn(\n styles.column,\n isDropTarget && styles.columnActive,\n column.locked && styles.columnLocked,\n )}\n >\n <div className={styles.columnHeader}>\n <span className={styles.columnTitle}>{column.title}</span>\n <span className={styles.count}>{column.cards.length}</span>\n </div>\n\n <div\n className={styles.cards}\n {...(column.cards.length > 0 ? sortable.getListProps() : {})}\n aria-label={typeof column.title === \"string\" ? column.title : column.id}\n >\n {column.cards.map((card, cardIndex) => {\n const index = columnOffsets[columnIndex] + cardIndex;\n const isActive = sortable.activeIndex === index;\n const isOver = sortable.overIndex === index && !isActive;\n return (\n <div\n key={card.id}\n {...sortable.getItemProps(index, column.id)}\n className={cn(\n styles.card,\n isActive && styles.cardActive,\n isOver && styles.cardOver,\n )}\n >\n {renderCard ? renderCard(card, column) : card.content}\n </div>\n );\n })}\n\n {column.cards.length === 0 ? (\n <div\n {...sortable.getEmptyGroupProps(column.id)}\n className={styles.empty}\n >\n {emptyLabel}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\n/**\n * Apply a {@link KanbanMove} to a column list, returning new arrays.\n *\n * Exported because every consumer needs the same reducer.\n *\n * `toIndex` is the position of the card that was dropped **onto**, read from the\n * board as it looked before the move. Inserting at that same index *after* removing\n * the dragged card lands where the user aimed in both directions — moving down, the\n * removal shifts the target up by one and the insertion lands after it; moving up,\n * nothing shifted. Compensating for the shift (the \"obvious\" `toIndex - 1`) is what\n * turns a one-step move down into a no-op.\n *\n * @param columns - Current board.\n * @param move - The move reported by `onMove`.\n * @returns A new column list, or the same reference when the move cannot apply.\n */\nexport function applyKanbanMove(columns: KanbanColumn[], move: KanbanMove): KanbanColumn[] {\n const fromColumn = columns.find((column) => column.id === move.fromColumn);\n const card = fromColumn?.cards.find((candidate) => candidate.id === move.cardId);\n if (!fromColumn || !card) return columns;\n\n const insertAt = move.toIndex;\n\n return columns.map((column) => {\n if (column.id === move.fromColumn && column.id === move.toColumn) {\n const without = column.cards.filter((candidate) => candidate.id !== move.cardId);\n without.splice(insertAt, 0, card);\n return { ...column, cards: without };\n }\n if (column.id === move.fromColumn) {\n return { ...column, cards: column.cards.filter((c) => c.id !== move.cardId) };\n }\n if (column.id === move.toColumn) {\n const next = column.cards.slice();\n next.splice(insertAt, 0, card);\n return { ...column, cards: next };\n }\n return column;\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"Kanban.cjs","names":[],"sources":["../../../src/components/Kanban/Kanban.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — drag-and-drop needs an\n * accessible twin: cardRoleDescription and label exist so the keyboard path\n * announces what the pointer path shows, and renderCard/emptyLabel are the slots.\n * The body is the move calculation shared by both paths.\n */\n/* eslint-disable react-hooks/refs -- `getItemProps` is called during render and its\n handlers close over `useSortable`'s mirror refs, so the rule reports the call site\n here. The refs are only written from event handlers and read from a window\n listener; the two reads that were genuinely render-time (the drag group in the\n rendered class/aria, and the group lookup inside `commit`) were fixed instead of\n silenced — see the hook's docstring. */\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useSortable } from \"@/hooks/use-sortable\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./Kanban.module.css\";\n\n/** One card on the board. */\nexport interface KanbanCard {\n /** Stable identifier, unique across the whole board. */\n id: string;\n /** Card body. A string renders as the card's title. */\n content: ReactNode;\n}\n\n/** One column of the board. */\nexport interface KanbanColumn {\n /** Stable identifier, used as the drop group. */\n id: string;\n /** Column heading. */\n title: ReactNode;\n /** Cards in display order. */\n cards: KanbanCard[];\n /**\n * Refuses drops into this column. The cards inside can still be picked up and\n * moved elsewhere — a \"done\" column that no longer accepts work, for instance.\n */\n locked?: boolean;\n}\n\n/** Where a card ended up. */\nexport interface KanbanMove {\n cardId: string;\n fromColumn: string;\n toColumn: string;\n /** Index within the destination column. */\n toIndex: number;\n}\n\nexport interface KanbanProps {\n columns: KanbanColumn[];\n /**\n * Called once per committed move — on pointer release or on a keyboard drop.\n * You own the data: apply the move and pass the new `columns` back.\n */\n onMove: (move: KanbanMove) => void;\n /** Renders a card. Defaults to its `content`. */\n renderCard?: (card: KanbanCard, column: KanbanColumn) => ReactNode;\n /** Text shown in a column with no cards. */\n emptyLabel?: ReactNode;\n /** Announced per card, so a screen reader user learns the keyboard contract. */\n cardRoleDescription?: string;\n /**\n * Accessible name of the board. A `listbox` without a name is unusable with a\n * screen reader, so this has a default rather than being optional in practice.\n */\n label?: string;\n /** Blocks all dragging. */\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Kanban board: columns of cards, reorderable within a column and movable across\n * columns, by pointer **or** keyboard.\n *\n * The drag machine is `useSortable` — the board does not reimplement it. Cards are\n * indexed globally across columns so a single sortable can span the whole board;\n * the component translates that flat index back into `{ column, index }` for\n * `onMove`, which is the shape an app actually needs.\n *\n * Nothing is mutated here: `onMove` reports the intent once and the parent applies\n * it. That keeps the board a controlled value and avoids a re-render per pointer\n * frame.\n *\n * ARIA shape: **one `listbox` per column**, named by the column title, containing\n * only `option` cards. A single board-wide listbox does not survive the markup a\n * board needs — `listbox` requires `option`/`group` children, and the column header\n * and card wrapper in between break that ownership. Per-column listboxes also read\n * better: a screen reader announces which column it is in and how many cards it\n * holds. The column header is a plain `div`, not a `<header>`, because outside a\n * sectioning element every `<header>` becomes a page-level banner landmark — with\n * three columns that is three duplicate banners. An **empty** column is not marked\n * as a listbox at all: a listbox with zero options fails `aria-required-children`,\n * and \"no cards\" is exactly zero options.\n *\n * Known limitation: a **keyboard** move can only target positions that hold a card,\n * because the move walks the flat index space of existing cards. Dropping into an\n * empty column works by pointer; by keyboard it does not. Moving a card to a column\n * that already has one card, then reordering, is the workaround until column-switch\n * keys land.\n *\n * @example\n * ```tsx\n * const [columns, setColumns] = useState<KanbanColumn[]>([\n * { id: \"todo\", title: \"A fazer\", cards: [{ id: \"1\", content: \"Corrigir login\" }] },\n * { id: \"doing\", title: \"Fazendo\", cards: [] },\n * { id: \"done\", title: \"Feito\", cards: [], locked: true },\n * ]);\n *\n * <Kanban\n * columns={columns}\n * onMove={({ cardId, fromColumn, toColumn, toIndex }) =>\n * setColumns((current) => applyKanbanMove(current, { cardId, fromColumn, toColumn, toIndex }))\n * }\n * />\n * ```\n */\nexport function Kanban({\n columns,\n onMove,\n renderCard,\n emptyLabel = \"Nenhum card\",\n cardRoleDescription = \"Card do quadro — Espaço pega, setas movem, Espaço solta\",\n label = \"Quadro\",\n disabled = false,\n className,\n}: KanbanProps) {\n /**\n * Flat view of every card, in column order.\n *\n * `useSortable` works on a single index space, so the board flattens itself and\n * keeps the mapping back to `{ column, index }` here — the only place that has\n * to know both shapes.\n */\n const flat = useMemo(\n () =>\n columns.flatMap((column, columnIndex) =>\n column.cards.map((card, cardIndex) => ({ card, column, columnIndex, cardIndex })),\n ),\n [columns],\n );\n\n const sortable = useSortable({\n itemCount: flat.length,\n disabled,\n roleDescription: cardRoleDescription,\n onReorder: ({ from, to, fromGroup, toGroup }) => {\n const source = flat[from];\n if (!source) return;\n\n const destinationId = toGroup ?? fromGroup ?? source.column.id;\n const destination = columns.find((column) => column.id === destinationId);\n if (!destination || destination.locked) return;\n\n const target = flat[to];\n const toIndex =\n target && target.column.id === destinationId\n ? target.cardIndex\n : destination.cards.length;\n\n onMove({\n cardId: source.card.id,\n fromColumn: source.column.id,\n toColumn: destinationId,\n toIndex,\n });\n },\n });\n\n /**\n * First flat index of each column.\n *\n * Derived instead of counted with a mutable `flatIndex++` during render: a\n * render-scoped counter mutated inside the map is exactly the pattern the React\n * Compiler rules reject, and for good reason — a partial or replayed render\n * would resume from a half-advanced counter.\n */\n const columnOffsets = useMemo(() => {\n const offsets: number[] = [];\n let start = 0;\n for (const column of columns) {\n offsets.push(start);\n start += column.cards.length;\n }\n return offsets;\n }, [columns]);\n\n return (\n <div className={cn(styles.board, className)} aria-label={label} ref={sortable.setContainer}>\n {columns.map((column, columnIndex) => {\n const isDropTarget = sortable.overGroup === column.id && !column.locked;\n return (\n <div\n key={column.id}\n className={cn(\n styles.column,\n isDropTarget && styles.columnActive,\n column.locked && styles.columnLocked,\n )}\n >\n <div className={styles.columnHeader}>\n <span className={styles.columnTitle}>{column.title}</span>\n <span className={styles.count}>{column.cards.length}</span>\n </div>\n\n <div\n className={styles.cards}\n {...(column.cards.length > 0 ? sortable.getListProps() : {})}\n aria-label={typeof column.title === \"string\" ? column.title : column.id}\n >\n {column.cards.map((card, cardIndex) => {\n const index = columnOffsets[columnIndex] + cardIndex;\n const isActive = sortable.activeIndex === index;\n const isOver = sortable.overIndex === index && !isActive;\n return (\n <div\n key={card.id}\n {...sortable.getItemProps(index, column.id)}\n className={cn(\n styles.card,\n isActive && styles.cardActive,\n isOver && styles.cardOver,\n )}\n >\n {renderCard ? renderCard(card, column) : card.content}\n </div>\n );\n })}\n\n {column.cards.length === 0 ? (\n <div\n {...sortable.getEmptyGroupProps(column.id)}\n className={styles.empty}\n >\n {emptyLabel}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\n/**\n * Apply a {@link KanbanMove} to a column list, returning new arrays.\n *\n * Exported because every consumer needs the same reducer.\n *\n * `toIndex` is the position of the card that was dropped **onto**, read from the\n * board as it looked before the move. Inserting at that same index *after* removing\n * the dragged card lands where the user aimed in both directions — moving down, the\n * removal shifts the target up by one and the insertion lands after it; moving up,\n * nothing shifted. Compensating for the shift (the \"obvious\" `toIndex - 1`) is what\n * turns a one-step move down into a no-op.\n *\n * @param columns - Current board.\n * @param move - The move reported by `onMove`.\n * @returns A new column list, or the same reference when the move cannot apply.\n */\nexport function applyKanbanMove(columns: KanbanColumn[], move: KanbanMove): KanbanColumn[] {\n const fromColumn = columns.find((column) => column.id === move.fromColumn);\n const card = fromColumn?.cards.find((candidate) => candidate.id === move.cardId);\n if (!fromColumn || !card) return columns;\n\n const insertAt = move.toIndex;\n\n return columns.map((column) => {\n if (column.id === move.fromColumn && column.id === move.toColumn) {\n const without = column.cards.filter((candidate) => candidate.id !== move.cardId);\n without.splice(insertAt, 0, card);\n return { ...column, cards: without };\n }\n if (column.id === move.fromColumn) {\n return { ...column, cards: column.cards.filter((c) => c.id !== move.cardId) };\n }\n if (column.id === move.toColumn) {\n const next = column.cards.slice();\n next.splice(insertAt, 0, card);\n return { ...column, cards: next };\n }\n return column;\n });\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"Kanban.js","names":[],"sources":["../../../src/components/Kanban/Kanban.tsx"],"sourcesContent":["/* eslint-disable react-hooks/refs -- `getItemProps` is called during render and its\n handlers close over `useSortable`'s mirror refs, so the rule reports the call site\n here. The refs are only written from event handlers and read from a window\n listener; the two reads that were genuinely render-time (the drag group in the\n rendered class/aria, and the group lookup inside `commit`) were fixed instead of\n silenced — see the hook's docstring. */\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useSortable } from \"@/hooks/use-sortable\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./Kanban.module.css\";\n\n/** One card on the board. */\nexport interface KanbanCard {\n /** Stable identifier, unique across the whole board. */\n id: string;\n /** Card body. A string renders as the card's title. */\n content: ReactNode;\n}\n\n/** One column of the board. */\nexport interface KanbanColumn {\n /** Stable identifier, used as the drop group. */\n id: string;\n /** Column heading. */\n title: ReactNode;\n /** Cards in display order. */\n cards: KanbanCard[];\n /**\n * Refuses drops into this column. The cards inside can still be picked up and\n * moved elsewhere — a \"done\" column that no longer accepts work, for instance.\n */\n locked?: boolean;\n}\n\n/** Where a card ended up. */\nexport interface KanbanMove {\n cardId: string;\n fromColumn: string;\n toColumn: string;\n /** Index within the destination column. */\n toIndex: number;\n}\n\nexport interface KanbanProps {\n columns: KanbanColumn[];\n /**\n * Called once per committed move — on pointer release or on a keyboard drop.\n * You own the data: apply the move and pass the new `columns` back.\n */\n onMove: (move: KanbanMove) => void;\n /** Renders a card. Defaults to its `content`. */\n renderCard?: (card: KanbanCard, column: KanbanColumn) => ReactNode;\n /** Text shown in a column with no cards. */\n emptyLabel?: ReactNode;\n /** Announced per card, so a screen reader user learns the keyboard contract. */\n cardRoleDescription?: string;\n /**\n * Accessible name of the board. A `listbox` without a name is unusable with a\n * screen reader, so this has a default rather than being optional in practice.\n */\n label?: string;\n /** Blocks all dragging. */\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Kanban board: columns of cards, reorderable within a column and movable across\n * columns, by pointer **or** keyboard.\n *\n * The drag machine is `useSortable` — the board does not reimplement it. Cards are\n * indexed globally across columns so a single sortable can span the whole board;\n * the component translates that flat index back into `{ column, index }` for\n * `onMove`, which is the shape an app actually needs.\n *\n * Nothing is mutated here: `onMove` reports the intent once and the parent applies\n * it. That keeps the board a controlled value and avoids a re-render per pointer\n * frame.\n *\n * ARIA shape: **one `listbox` per column**, named by the column title, containing\n * only `option` cards. A single board-wide listbox does not survive the markup a\n * board needs — `listbox` requires `option`/`group` children, and the column header\n * and card wrapper in between break that ownership. Per-column listboxes also read\n * better: a screen reader announces which column it is in and how many cards it\n * holds. The column header is a plain `div`, not a `<header>`, because outside a\n * sectioning element every `<header>` becomes a page-level banner landmark — with\n * three columns that is three duplicate banners. An **empty** column is not marked\n * as a listbox at all: a listbox with zero options fails `aria-required-children`,\n * and \"no cards\" is exactly zero options.\n *\n * Known limitation: a **keyboard** move can only target positions that hold a card,\n * because the move walks the flat index space of existing cards. Dropping into an\n * empty column works by pointer; by keyboard it does not. Moving a card to a column\n * that already has one card, then reordering, is the workaround until column-switch\n * keys land.\n *\n * @example\n * ```tsx\n * const [columns, setColumns] = useState<KanbanColumn[]>([\n * { id: \"todo\", title: \"A fazer\", cards: [{ id: \"1\", content: \"Corrigir login\" }] },\n * { id: \"doing\", title: \"Fazendo\", cards: [] },\n * { id: \"done\", title: \"Feito\", cards: [], locked: true },\n * ]);\n *\n * <Kanban\n * columns={columns}\n * onMove={({ cardId, fromColumn, toColumn, toIndex }) =>\n * setColumns((current) => applyKanbanMove(current, { cardId, fromColumn, toColumn, toIndex }))\n * }\n * />\n * ```\n */\nexport function Kanban({\n columns,\n onMove,\n renderCard,\n emptyLabel = \"Nenhum card\",\n cardRoleDescription = \"Card do quadro — Espaço pega, setas movem, Espaço solta\",\n label = \"Quadro\",\n disabled = false,\n className,\n}: KanbanProps) {\n /**\n * Flat view of every card, in column order.\n *\n * `useSortable` works on a single index space, so the board flattens itself and\n * keeps the mapping back to `{ column, index }` here — the only place that has\n * to know both shapes.\n */\n const flat = useMemo(\n () =>\n columns.flatMap((column, columnIndex) =>\n column.cards.map((card, cardIndex) => ({ card, column, columnIndex, cardIndex })),\n ),\n [columns],\n );\n\n const sortable = useSortable({\n itemCount: flat.length,\n disabled,\n roleDescription: cardRoleDescription,\n onReorder: ({ from, to, fromGroup, toGroup }) => {\n const source = flat[from];\n if (!source) return;\n\n const destinationId = toGroup ?? fromGroup ?? source.column.id;\n const destination = columns.find((column) => column.id === destinationId);\n if (!destination || destination.locked) return;\n\n const target = flat[to];\n const toIndex =\n target && target.column.id === destinationId\n ? target.cardIndex\n : destination.cards.length;\n\n onMove({\n cardId: source.card.id,\n fromColumn: source.column.id,\n toColumn: destinationId,\n toIndex,\n });\n },\n });\n\n /**\n * First flat index of each column.\n *\n * Derived instead of counted with a mutable `flatIndex++` during render: a\n * render-scoped counter mutated inside the map is exactly the pattern the React\n * Compiler rules reject, and for good reason — a partial or replayed render\n * would resume from a half-advanced counter.\n */\n const columnOffsets = useMemo(() => {\n const offsets: number[] = [];\n let start = 0;\n for (const column of columns) {\n offsets.push(start);\n start += column.cards.length;\n }\n return offsets;\n }, [columns]);\n\n return (\n <div className={cn(styles.board, className)} aria-label={label} ref={sortable.setContainer}>\n {columns.map((column, columnIndex) => {\n const isDropTarget = sortable.overGroup === column.id && !column.locked;\n return (\n <div\n key={column.id}\n className={cn(\n styles.column,\n isDropTarget && styles.columnActive,\n column.locked && styles.columnLocked,\n )}\n >\n <div className={styles.columnHeader}>\n <span className={styles.columnTitle}>{column.title}</span>\n <span className={styles.count}>{column.cards.length}</span>\n </div>\n\n <div\n className={styles.cards}\n {...(column.cards.length > 0 ? sortable.getListProps() : {})}\n aria-label={typeof column.title === \"string\" ? column.title : column.id}\n >\n {column.cards.map((card, cardIndex) => {\n const index = columnOffsets[columnIndex] + cardIndex;\n const isActive = sortable.activeIndex === index;\n const isOver = sortable.overIndex === index && !isActive;\n return (\n <div\n key={card.id}\n {...sortable.getItemProps(index, column.id)}\n className={cn(\n styles.card,\n isActive && styles.cardActive,\n isOver && styles.cardOver,\n )}\n >\n {renderCard ? renderCard(card, column) : card.content}\n </div>\n );\n })}\n\n {column.cards.length === 0 ? (\n <div\n {...sortable.getEmptyGroupProps(column.id)}\n className={styles.empty}\n >\n {emptyLabel}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\n/**\n * Apply a {@link KanbanMove} to a column list, returning new arrays.\n *\n * Exported because every consumer needs the same reducer.\n *\n * `toIndex` is the position of the card that was dropped **onto**, read from the\n * board as it looked before the move. Inserting at that same index *after* removing\n * the dragged card lands where the user aimed in both directions — moving down, the\n * removal shifts the target up by one and the insertion lands after it; moving up,\n * nothing shifted. Compensating for the shift (the \"obvious\" `toIndex - 1`) is what\n * turns a one-step move down into a no-op.\n *\n * @param columns - Current board.\n * @param move - The move reported by `onMove`.\n * @returns A new column list, or the same reference when the move cannot apply.\n */\nexport function applyKanbanMove(columns: KanbanColumn[], move: KanbanMove): KanbanColumn[] {\n const fromColumn = columns.find((column) => column.id === move.fromColumn);\n const card = fromColumn?.cards.find((candidate) => candidate.id === move.cardId);\n if (!fromColumn || !card) return columns;\n\n const insertAt = move.toIndex;\n\n return columns.map((column) => {\n if (column.id === move.fromColumn && column.id === move.toColumn) {\n const without = column.cards.filter((candidate) => candidate.id !== move.cardId);\n without.splice(insertAt, 0, card);\n return { ...column, cards: without };\n }\n if (column.id === move.fromColumn) {\n return { ...column, cards: column.cards.filter((c) => c.id !== move.cardId) };\n }\n if (column.id === move.toColumn) {\n const next = column.cards.slice();\n next.splice(insertAt, 0, card);\n return { ...column, cards: next };\n }\n return column;\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"Kanban.js","names":[],"sources":["../../../src/components/Kanban/Kanban.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — drag-and-drop needs an\n * accessible twin: cardRoleDescription and label exist so the keyboard path\n * announces what the pointer path shows, and renderCard/emptyLabel are the slots.\n * The body is the move calculation shared by both paths.\n */\n/* eslint-disable react-hooks/refs -- `getItemProps` is called during render and its\n handlers close over `useSortable`'s mirror refs, so the rule reports the call site\n here. The refs are only written from event handlers and read from a window\n listener; the two reads that were genuinely render-time (the drag group in the\n rendered class/aria, and the group lookup inside `commit`) were fixed instead of\n silenced — see the hook's docstring. */\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useSortable } from \"@/hooks/use-sortable\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./Kanban.module.css\";\n\n/** One card on the board. */\nexport interface KanbanCard {\n /** Stable identifier, unique across the whole board. */\n id: string;\n /** Card body. A string renders as the card's title. */\n content: ReactNode;\n}\n\n/** One column of the board. */\nexport interface KanbanColumn {\n /** Stable identifier, used as the drop group. */\n id: string;\n /** Column heading. */\n title: ReactNode;\n /** Cards in display order. */\n cards: KanbanCard[];\n /**\n * Refuses drops into this column. The cards inside can still be picked up and\n * moved elsewhere — a \"done\" column that no longer accepts work, for instance.\n */\n locked?: boolean;\n}\n\n/** Where a card ended up. */\nexport interface KanbanMove {\n cardId: string;\n fromColumn: string;\n toColumn: string;\n /** Index within the destination column. */\n toIndex: number;\n}\n\nexport interface KanbanProps {\n columns: KanbanColumn[];\n /**\n * Called once per committed move — on pointer release or on a keyboard drop.\n * You own the data: apply the move and pass the new `columns` back.\n */\n onMove: (move: KanbanMove) => void;\n /** Renders a card. Defaults to its `content`. */\n renderCard?: (card: KanbanCard, column: KanbanColumn) => ReactNode;\n /** Text shown in a column with no cards. */\n emptyLabel?: ReactNode;\n /** Announced per card, so a screen reader user learns the keyboard contract. */\n cardRoleDescription?: string;\n /**\n * Accessible name of the board. A `listbox` without a name is unusable with a\n * screen reader, so this has a default rather than being optional in practice.\n */\n label?: string;\n /** Blocks all dragging. */\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Kanban board: columns of cards, reorderable within a column and movable across\n * columns, by pointer **or** keyboard.\n *\n * The drag machine is `useSortable` — the board does not reimplement it. Cards are\n * indexed globally across columns so a single sortable can span the whole board;\n * the component translates that flat index back into `{ column, index }` for\n * `onMove`, which is the shape an app actually needs.\n *\n * Nothing is mutated here: `onMove` reports the intent once and the parent applies\n * it. That keeps the board a controlled value and avoids a re-render per pointer\n * frame.\n *\n * ARIA shape: **one `listbox` per column**, named by the column title, containing\n * only `option` cards. A single board-wide listbox does not survive the markup a\n * board needs — `listbox` requires `option`/`group` children, and the column header\n * and card wrapper in between break that ownership. Per-column listboxes also read\n * better: a screen reader announces which column it is in and how many cards it\n * holds. The column header is a plain `div`, not a `<header>`, because outside a\n * sectioning element every `<header>` becomes a page-level banner landmark — with\n * three columns that is three duplicate banners. An **empty** column is not marked\n * as a listbox at all: a listbox with zero options fails `aria-required-children`,\n * and \"no cards\" is exactly zero options.\n *\n * Known limitation: a **keyboard** move can only target positions that hold a card,\n * because the move walks the flat index space of existing cards. Dropping into an\n * empty column works by pointer; by keyboard it does not. Moving a card to a column\n * that already has one card, then reordering, is the workaround until column-switch\n * keys land.\n *\n * @example\n * ```tsx\n * const [columns, setColumns] = useState<KanbanColumn[]>([\n * { id: \"todo\", title: \"A fazer\", cards: [{ id: \"1\", content: \"Corrigir login\" }] },\n * { id: \"doing\", title: \"Fazendo\", cards: [] },\n * { id: \"done\", title: \"Feito\", cards: [], locked: true },\n * ]);\n *\n * <Kanban\n * columns={columns}\n * onMove={({ cardId, fromColumn, toColumn, toIndex }) =>\n * setColumns((current) => applyKanbanMove(current, { cardId, fromColumn, toColumn, toIndex }))\n * }\n * />\n * ```\n */\nexport function Kanban({\n columns,\n onMove,\n renderCard,\n emptyLabel = \"Nenhum card\",\n cardRoleDescription = \"Card do quadro — Espaço pega, setas movem, Espaço solta\",\n label = \"Quadro\",\n disabled = false,\n className,\n}: KanbanProps) {\n /**\n * Flat view of every card, in column order.\n *\n * `useSortable` works on a single index space, so the board flattens itself and\n * keeps the mapping back to `{ column, index }` here — the only place that has\n * to know both shapes.\n */\n const flat = useMemo(\n () =>\n columns.flatMap((column, columnIndex) =>\n column.cards.map((card, cardIndex) => ({ card, column, columnIndex, cardIndex })),\n ),\n [columns],\n );\n\n const sortable = useSortable({\n itemCount: flat.length,\n disabled,\n roleDescription: cardRoleDescription,\n onReorder: ({ from, to, fromGroup, toGroup }) => {\n const source = flat[from];\n if (!source) return;\n\n const destinationId = toGroup ?? fromGroup ?? source.column.id;\n const destination = columns.find((column) => column.id === destinationId);\n if (!destination || destination.locked) return;\n\n const target = flat[to];\n const toIndex =\n target && target.column.id === destinationId\n ? target.cardIndex\n : destination.cards.length;\n\n onMove({\n cardId: source.card.id,\n fromColumn: source.column.id,\n toColumn: destinationId,\n toIndex,\n });\n },\n });\n\n /**\n * First flat index of each column.\n *\n * Derived instead of counted with a mutable `flatIndex++` during render: a\n * render-scoped counter mutated inside the map is exactly the pattern the React\n * Compiler rules reject, and for good reason — a partial or replayed render\n * would resume from a half-advanced counter.\n */\n const columnOffsets = useMemo(() => {\n const offsets: number[] = [];\n let start = 0;\n for (const column of columns) {\n offsets.push(start);\n start += column.cards.length;\n }\n return offsets;\n }, [columns]);\n\n return (\n <div className={cn(styles.board, className)} aria-label={label} ref={sortable.setContainer}>\n {columns.map((column, columnIndex) => {\n const isDropTarget = sortable.overGroup === column.id && !column.locked;\n return (\n <div\n key={column.id}\n className={cn(\n styles.column,\n isDropTarget && styles.columnActive,\n column.locked && styles.columnLocked,\n )}\n >\n <div className={styles.columnHeader}>\n <span className={styles.columnTitle}>{column.title}</span>\n <span className={styles.count}>{column.cards.length}</span>\n </div>\n\n <div\n className={styles.cards}\n {...(column.cards.length > 0 ? sortable.getListProps() : {})}\n aria-label={typeof column.title === \"string\" ? column.title : column.id}\n >\n {column.cards.map((card, cardIndex) => {\n const index = columnOffsets[columnIndex] + cardIndex;\n const isActive = sortable.activeIndex === index;\n const isOver = sortable.overIndex === index && !isActive;\n return (\n <div\n key={card.id}\n {...sortable.getItemProps(index, column.id)}\n className={cn(\n styles.card,\n isActive && styles.cardActive,\n isOver && styles.cardOver,\n )}\n >\n {renderCard ? renderCard(card, column) : card.content}\n </div>\n );\n })}\n\n {column.cards.length === 0 ? (\n <div\n {...sortable.getEmptyGroupProps(column.id)}\n className={styles.empty}\n >\n {emptyLabel}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\n/**\n * Apply a {@link KanbanMove} to a column list, returning new arrays.\n *\n * Exported because every consumer needs the same reducer.\n *\n * `toIndex` is the position of the card that was dropped **onto**, read from the\n * board as it looked before the move. Inserting at that same index *after* removing\n * the dragged card lands where the user aimed in both directions — moving down, the\n * removal shifts the target up by one and the insertion lands after it; moving up,\n * nothing shifted. Compensating for the shift (the \"obvious\" `toIndex - 1`) is what\n * turns a one-step move down into a no-op.\n *\n * @param columns - Current board.\n * @param move - The move reported by `onMove`.\n * @returns A new column list, or the same reference when the move cannot apply.\n */\nexport function applyKanbanMove(columns: KanbanColumn[], move: KanbanMove): KanbanColumn[] {\n const fromColumn = columns.find((column) => column.id === move.fromColumn);\n const card = fromColumn?.cards.find((candidate) => candidate.id === move.cardId);\n if (!fromColumn || !card) return columns;\n\n const insertAt = move.toIndex;\n\n return columns.map((column) => {\n if (column.id === move.fromColumn && column.id === move.toColumn) {\n const without = column.cards.filter((candidate) => candidate.id !== move.cardId);\n without.splice(insertAt, 0, card);\n return { ...column, cards: without };\n }\n if (column.id === move.fromColumn) {\n return { ...column, cards: column.cards.filter((c) => c.id !== move.cardId) };\n }\n if (column.id === move.toColumn) {\n const next = column.cards.slice();\n next.splice(insertAt, 0, card);\n return { ...column, cards: next };\n }\n return column;\n });\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"Lightbox.cjs","names":[],"sources":["../../../src/components/Lightbox/Lightbox.tsx"],"sourcesContent":["import { ChevronLeft, ChevronRight, X } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useFocusTrap } from \"@/hooks/use-focus-trap\";\nimport { useScrollLock } from \"@/hooks/use-scroll-lock\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"../Portal\";\nimport styles from \"./Lightbox.module.css\";\n\n/** One item of the gallery. */\nexport interface LightboxItem {\n /** Full-size image URL. */\n src: string;\n /** Alt text. Required: a gallery of unlabeled images is unusable with a screen reader. */\n alt: string;\n /** Caption rendered under the image. */\n caption?: string;\n /** Thumbnail URL for the strip. Falls back to `src`. */\n thumbnail?: string;\n}\n\nexport interface LightboxProps {\n items: LightboxItem[];\n /** Whether the overlay is open. */\n open: boolean;\n /** Index shown while open. Default `0`. */\n index?: number;\n onIndexChange?: (index: number) => void;\n onClose: () => void;\n /** Renders the thumbnail strip. Default `true` when there is more than one item. */\n showThumbnails?: boolean;\n /** Renders the `3 / 12` counter. Default `true`. */\n showCounter?: boolean;\n /**\n * Wrap around at the ends. Default `true` — in a photo viewer, hitting a dead\n * end at the last image reads as a bug more often than as a boundary.\n */\n loop?: boolean;\n closeLabel?: string;\n previousLabel?: string;\n nextLabel?: string;\n className?: string;\n}\n\n/**\n * Full-screen image viewer with keyboard navigation.\n *\n * `Esc` closes, `←`/`→` walk the gallery, `Home`/`End` jump to the ends. Focus is\n * trapped inside while open and the page behind is scroll-locked, so the overlay\n * behaves like the dialog it is instead of a fancy `<div>` the keyboard can escape.\n *\n * Only the current image is mounted; neighbours are **preloaded** via `Image()` so\n * pressing `→` does not flash an empty frame.\n *\n * @example\n * ```tsx\n * const [open, setOpen] = useState(false);\n * const [index, setIndex] = useState(0);\n *\n * <Lightbox\n * open={open}\n * items={photos.map((p) => ({ src: p.url, alt: p.description }))}\n * index={index}\n * onIndexChange={setIndex}\n * onClose={() => setOpen(false)}\n * />\n * ```\n */\nexport function Lightbox({\n items,\n open,\n index = 0,\n onIndexChange,\n onClose,\n showThumbnails,\n showCounter = true,\n loop = true,\n closeLabel = \"Close\",\n previousLabel = \"Previous image\",\n nextLabel = \"Next image\",\n className,\n}: LightboxProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [internalIndex, setInternalIndex] = useState(index);\n const isControlled = onIndexChange !== undefined;\n const current = isControlled ? index : internalIndex;\n\n useFocusTrap(containerRef, open);\n useScrollLock(open);\n\n const move = useCallback(\n (next: number): void => {\n if (items.length === 0) return;\n const target = loop\n ? (next + items.length) % items.length\n : Math.max(0, Math.min(next, items.length - 1));\n if (!isControlled) setInternalIndex(target);\n onIndexChange?.(target);\n },\n [isControlled, items.length, loop, onIndexChange],\n );\n\n useEffect(() => {\n if (!isControlled) setInternalIndex(index);\n }, [index, isControlled]);\n\n useEffect(() => {\n if (!open) return;\n function onKeyDown(event: KeyboardEvent): void {\n switch (event.key) {\n case \"Escape\":\n onClose();\n break;\n case \"ArrowRight\":\n move(current + 1);\n break;\n case \"ArrowLeft\":\n move(current - 1);\n break;\n case \"Home\":\n move(0);\n break;\n case \"End\":\n move(items.length - 1);\n break;\n default:\n return;\n }\n event.preventDefault();\n }\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [current, items.length, move, onClose, open]);\n\n useEffect(() => {\n if (!open || typeof Image === \"undefined\") return;\n for (const offset of [-1, 1]) {\n const neighbour = items[(current + offset + items.length) % items.length];\n if (!neighbour) continue;\n const preload = new Image();\n preload.src = neighbour.src;\n }\n }, [current, items, open]);\n\n if (!open || items.length === 0) return null;\n\n const item = items[Math.max(0, Math.min(current, items.length - 1))];\n const withThumbnails = showThumbnails ?? items.length > 1;\n const multiple = items.length > 1;\n\n return (\n <Portal>\n <div\n ref={containerRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={item.alt}\n className={cn(styles.overlay, className)}\n >\n <div className={styles.topBar}>\n {showCounter && multiple ? (\n <span className={styles.counter}>\n {current + 1} / {items.length}\n </span>\n ) : (\n <span />\n )}\n <button\n type=\"button\"\n className={styles.iconButton}\n aria-label={closeLabel}\n onClick={onClose}\n >\n <X size={20} />\n </button>\n </div>\n\n <div className={styles.stage}>\n {multiple ? (\n <button\n type=\"button\"\n className={cn(styles.iconButton, styles.nav)}\n aria-label={previousLabel}\n disabled={!loop && current === 0}\n onClick={() => move(current - 1)}\n >\n <ChevronLeft size={24} />\n </button>\n ) : null}\n\n <figure className={styles.figure}>\n <img src={item.src} alt={item.alt} className={styles.image} />\n {item.caption ? (\n <figcaption className={styles.caption}>{item.caption}</figcaption>\n ) : null}\n </figure>\n\n {multiple ? (\n <button\n type=\"button\"\n className={cn(styles.iconButton, styles.nav)}\n aria-label={nextLabel}\n disabled={!loop && current === items.length - 1}\n onClick={() => move(current + 1)}\n >\n <ChevronRight size={24} />\n </button>\n ) : null}\n </div>\n\n {withThumbnails && multiple ? (\n <div className={styles.thumbnails} role=\"tablist\" aria-label={item.alt}>\n {items.map((entry, entryIndex) => (\n <button\n key={`${entry.src}-${entryIndex}`}\n type=\"button\"\n role=\"tab\"\n aria-selected={entryIndex === current}\n aria-label={entry.alt}\n className={cn(\n styles.thumbnail,\n entryIndex === current && styles.thumbnailActive,\n )}\n onClick={() => move(entryIndex)}\n >\n <img\n src={entry.thumbnail ?? entry.src}\n alt=\"\"\n className={styles.thumbnailImage}\n />\n </button>\n ))}\n </div>\n ) : null}\n </div>\n </Portal>\n );\n}\n"],"mappings":"oRAmEA,SAAgB,EAAS,CACrB,QACA,OACA,QAAQ,EACR,gBACA,UACA,iBACA,cAAc,GACd,OAAO,GACP,aAAa,QACb,gBAAgB,iBAChB,YAAY,aACZ,aACc,CACd,IAAM,GAAA,EAAA,EAAA,OAAA,CAAsC,IAAI,EAC1C,CAAC,EAAe,IAAA,EAAA,EAAA,SAAA,CAA6B,CAAK,EAClD,EAAe,IAAkB,IAAA,GACjC,EAAU,EAAe,EAAQ,EAEvC,EAAA,aAAa,EAAc,CAAI,EAC/B,EAAA,cAAc,CAAI,EAElB,IAAM,GAAA,EAAA,EAAA,YAAA,CACD,GAAuB,CACpB,GAAI,EAAM,SAAW,EAAG,OACxB,IAAM,EAAS,GACR,EAAO,EAAM,QAAU,EAAM,OAC9B,KAAK,IAAI,EAAG,KAAK,IAAI,EAAM,EAAM,OAAS,CAAC,CAAC,EAC7C,GAAc,EAAiB,CAAM,EAC1C,IAAgB,CAAM,CAC1B,EACA,CAAC,EAAc,EAAM,OAAQ,EAAM,CAAa,CACpD,EA4CA,IA1CA,EAAA,EAAA,UAAA,KAAgB,CACP,GAAc,EAAiB,CAAK,CAC7C,EAAG,CAAC,EAAO,CAAY,CAAC,GAExB,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,CAAC,EAAM,OACX,SAAS,EAAU,EAA4B,CAC3C,OAAQ,EAAM,IAAd,CACI,IAAK,SACD,EAAQ,EACR,MACJ,IAAK,aACD,EAAK,EAAU,CAAC,EAChB,MACJ,IAAK,YACD,EAAK,EAAU,CAAC,EAChB,MACJ,IAAK,OACD,EAAK,CAAC,EACN,MACJ,IAAK,MACD,EAAK,EAAM,OAAS,CAAC,EACrB,MACJ,QACI,MACR,CACA,EAAM,eAAe,CACzB,CAEA,OADA,SAAS,iBAAiB,UAAW,CAAS,MACjC,SAAS,oBAAoB,UAAW,CAAS,CAClE,EAAG,CAAC,EAAS,EAAM,OAAQ,EAAM,EAAS,CAAI,CAAC,GAE/C,EAAA,EAAA,UAAA,KAAgB,CACR,MAAC,GAAQ,OAAO,MAAU,KAC9B,IAAK,IAAM,IAAU,CAAC,GAAI,CAAC,EAAG,CAC1B,IAAM,EAAY,GAAO,EAAU,EAAS,EAAM,QAAU,EAAM,QAClE,GAAI,CAAC,EAAW,SAChB,IAAM,EAAU,IAAI,MACpB,EAAQ,IAAM,EAAU,GAC5B,CACJ,EAAG,CAAC,EAAS,EAAO,CAAI,CAAC,EAErB,CAAC,GAAQ,EAAM,SAAW,EAAG,OAAO,KAExC,IAAM,EAAO,EAAM,KAAK,IAAI,EAAG,KAAK,IAAI,EAAS,EAAM,OAAS,CAAC,CAAC,GAC5D,EAAiB,GAAkB,EAAM,OAAS,EAClD,EAAW,EAAM,OAAS,EAEhC,OACI,EAAA,EAAA,IAAA,CAAC,EAAA,OAAD,CAAA,UACI,EAAA,EAAA,KAAA,CAAC,MAAD,CACI,IAAK,EACL,KAAK,SACL,aAAW,OACX,aAAY,EAAK,IACjB,UAAW,EAAA,GAAG,EAAA,QAAO,QAAS,CAAS,WAL3C,EAOI,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,gBAAvB,CACK,GAAe,GACZ,EAAA,EAAA,KAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,iBAAxB,CACK,EAAU,EAAE,MAAI,EAAM,MACrB,KAEN,EAAA,EAAA,IAAA,CAAC,OAAD,CAAO,CAAA,GAEX,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,UAAW,EAAA,QAAO,WAClB,aAAY,EACZ,QAAS,YAET,EAAA,EAAA,IAAA,CAAC,EAAA,EAAD,CAAG,KAAM,EAAK,CAAA,CACV,CAAA,CACP,KAEL,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,eAAvB,CACK,GACG,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,UAAW,EAAA,GAAG,EAAA,QAAO,WAAY,EAAA,QAAO,GAAG,EAC3C,aAAY,EACZ,SAAU,CAAC,GAAQ,IAAY,EAC/B,YAAe,EAAK,EAAU,CAAC,YAE/B,EAAA,EAAA,IAAA,CAAC,EAAA,YAAD,CAAa,KAAM,EAAK,CAAA,CACpB,CAAA,EACR,MAEJ,EAAA,EAAA,KAAA,CAAC,SAAD,CAAQ,UAAW,EAAA,QAAO,gBAA1B,EACI,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,IAAK,EAAK,IAAK,IAAK,EAAK,IAAK,UAAW,EAAA,QAAO,KAAQ,CAAA,EAC5D,EAAK,SACF,EAAA,EAAA,IAAA,CAAC,aAAD,CAAY,UAAW,EAAA,QAAO,iBAAU,EAAK,OAAoB,CAAA,EACjE,IACA,IAEP,GACG,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,UAAW,EAAA,GAAG,EAAA,QAAO,WAAY,EAAA,QAAO,GAAG,EAC3C,aAAY,EACZ,SAAU,CAAC,GAAQ,IAAY,EAAM,OAAS,EAC9C,YAAe,EAAK,EAAU,CAAC,YAE/B,EAAA,EAAA,IAAA,CAAC,EAAA,aAAD,CAAc,KAAM,EAAK,CAAA,CACrB,CAAA,EACR,IACH,IAEJ,GAAkB,GACf,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,WAAY,KAAK,UAAU,aAAY,EAAK,aAC9D,EAAM,KAAK,EAAO,KACf,EAAA,EAAA,IAAA,CAAC,SAAD,CAEI,KAAK,SACL,KAAK,MACL,gBAAe,IAAe,EAC9B,aAAY,EAAM,IAClB,UAAW,EAAA,GACP,EAAA,QAAO,UACP,IAAe,GAAW,EAAA,QAAO,eACrC,EACA,YAAe,EAAK,CAAU,YAE9B,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,IAAK,EAAM,WAAa,EAAM,IAC9B,IAAI,GACJ,UAAW,EAAA,QAAO,cACrB,CAAA,CACG,EAhBC,GAAG,EAAM,IAAI,GAAG,GAgBjB,CACX,CACA,CAAA,EACL,IACH,GACD,CAAA,CAEhB"}
1
+ {"version":3,"file":"Lightbox.cjs","names":[],"sources":["../../../src/components/Lightbox/Lightbox.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — closeLabel,\n * previousLabel and nextLabel are three separate strings an app has to translate,\n * showThumbnails/showCounter/loop are the chrome, and items/index/\n * onIndexChange/open/onClose are the controlled contract. The body is the focus\n * trap, the key handling and the swipe, which all move the same index.\n */\nimport { ChevronLeft, ChevronRight, X } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useFocusTrap } from \"@/hooks/use-focus-trap\";\nimport { useScrollLock } from \"@/hooks/use-scroll-lock\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"../Portal\";\nimport styles from \"./Lightbox.module.css\";\n\n/** One item of the gallery. */\nexport interface LightboxItem {\n /** Full-size image URL. */\n src: string;\n /** Alt text. Required: a gallery of unlabeled images is unusable with a screen reader. */\n alt: string;\n /** Caption rendered under the image. */\n caption?: string;\n /** Thumbnail URL for the strip. Falls back to `src`. */\n thumbnail?: string;\n}\n\nexport interface LightboxProps {\n items: LightboxItem[];\n /** Whether the overlay is open. */\n open: boolean;\n /** Index shown while open. Default `0`. */\n index?: number;\n onIndexChange?: (index: number) => void;\n onClose: () => void;\n /** Renders the thumbnail strip. Default `true` when there is more than one item. */\n showThumbnails?: boolean;\n /** Renders the `3 / 12` counter. Default `true`. */\n showCounter?: boolean;\n /**\n * Wrap around at the ends. Default `true` — in a photo viewer, hitting a dead\n * end at the last image reads as a bug more often than as a boundary.\n */\n loop?: boolean;\n closeLabel?: string;\n previousLabel?: string;\n nextLabel?: string;\n className?: string;\n}\n\n/**\n * Full-screen image viewer with keyboard navigation.\n *\n * `Esc` closes, `←`/`→` walk the gallery, `Home`/`End` jump to the ends. Focus is\n * trapped inside while open and the page behind is scroll-locked, so the overlay\n * behaves like the dialog it is instead of a fancy `<div>` the keyboard can escape.\n *\n * Only the current image is mounted; neighbours are **preloaded** via `Image()` so\n * pressing `→` does not flash an empty frame.\n *\n * @example\n * ```tsx\n * const [open, setOpen] = useState(false);\n * const [index, setIndex] = useState(0);\n *\n * <Lightbox\n * open={open}\n * items={photos.map((p) => ({ src: p.url, alt: p.description }))}\n * index={index}\n * onIndexChange={setIndex}\n * onClose={() => setOpen(false)}\n * />\n * ```\n */\nexport function Lightbox({\n items,\n open,\n index = 0,\n onIndexChange,\n onClose,\n showThumbnails,\n showCounter = true,\n loop = true,\n closeLabel = \"Close\",\n previousLabel = \"Previous image\",\n nextLabel = \"Next image\",\n className,\n}: LightboxProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [internalIndex, setInternalIndex] = useState(index);\n const isControlled = onIndexChange !== undefined;\n const current = isControlled ? index : internalIndex;\n\n useFocusTrap(containerRef, open);\n useScrollLock(open);\n\n const move = useCallback(\n (next: number): void => {\n if (items.length === 0) return;\n const target = loop\n ? (next + items.length) % items.length\n : Math.max(0, Math.min(next, items.length - 1));\n if (!isControlled) setInternalIndex(target);\n onIndexChange?.(target);\n },\n [isControlled, items.length, loop, onIndexChange],\n );\n\n useEffect(() => {\n if (!isControlled) setInternalIndex(index);\n }, [index, isControlled]);\n\n useEffect(() => {\n if (!open) return;\n function onKeyDown(event: KeyboardEvent): void {\n switch (event.key) {\n case \"Escape\":\n onClose();\n break;\n case \"ArrowRight\":\n move(current + 1);\n break;\n case \"ArrowLeft\":\n move(current - 1);\n break;\n case \"Home\":\n move(0);\n break;\n case \"End\":\n move(items.length - 1);\n break;\n default:\n return;\n }\n event.preventDefault();\n }\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [current, items.length, move, onClose, open]);\n\n useEffect(() => {\n if (!open || typeof Image === \"undefined\") return;\n for (const offset of [-1, 1]) {\n const neighbour = items[(current + offset + items.length) % items.length];\n if (!neighbour) continue;\n const preload = new Image();\n preload.src = neighbour.src;\n }\n }, [current, items, open]);\n\n if (!open || items.length === 0) return null;\n\n const item = items[Math.max(0, Math.min(current, items.length - 1))];\n const withThumbnails = showThumbnails ?? items.length > 1;\n const multiple = items.length > 1;\n\n return (\n <Portal>\n <div\n ref={containerRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={item.alt}\n className={cn(styles.overlay, className)}\n >\n <div className={styles.topBar}>\n {showCounter && multiple ? (\n <span className={styles.counter}>\n {current + 1} / {items.length}\n </span>\n ) : (\n <span />\n )}\n <button\n type=\"button\"\n className={styles.iconButton}\n aria-label={closeLabel}\n onClick={onClose}\n >\n <X size={20} />\n </button>\n </div>\n\n <div className={styles.stage}>\n {multiple ? (\n <button\n type=\"button\"\n className={cn(styles.iconButton, styles.nav)}\n aria-label={previousLabel}\n disabled={!loop && current === 0}\n onClick={() => move(current - 1)}\n >\n <ChevronLeft size={24} />\n </button>\n ) : null}\n\n <figure className={styles.figure}>\n <img src={item.src} alt={item.alt} className={styles.image} />\n {item.caption ? (\n <figcaption className={styles.caption}>{item.caption}</figcaption>\n ) : null}\n </figure>\n\n {multiple ? (\n <button\n type=\"button\"\n className={cn(styles.iconButton, styles.nav)}\n aria-label={nextLabel}\n disabled={!loop && current === items.length - 1}\n onClick={() => move(current + 1)}\n >\n <ChevronRight size={24} />\n </button>\n ) : null}\n </div>\n\n {withThumbnails && multiple ? (\n <div className={styles.thumbnails} role=\"tablist\" aria-label={item.alt}>\n {items.map((entry, entryIndex) => (\n <button\n key={`${entry.src}-${entryIndex}`}\n type=\"button\"\n role=\"tab\"\n aria-selected={entryIndex === current}\n aria-label={entry.alt}\n className={cn(\n styles.thumbnail,\n entryIndex === current && styles.thumbnailActive,\n )}\n onClick={() => move(entryIndex)}\n >\n <img\n src={entry.thumbnail ?? entry.src}\n alt=\"\"\n className={styles.thumbnailImage}\n />\n </button>\n ))}\n </div>\n ) : null}\n </div>\n </Portal>\n );\n}\n"],"mappings":"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"}