@happyvertical/smrt-ui 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/AGENTS.md +11 -1
  2. package/README.md +300 -2
  3. package/dist/components/data/DataTable.svelte +60 -15
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableResponsive.d.ts +4 -0
  6. package/dist/components/data/DataTableResponsive.d.ts.map +1 -0
  7. package/dist/components/data/DataTableResponsive.js +22 -0
  8. package/dist/components/data/__tests__/DataTableResponsive.test.js +80 -0
  9. package/dist/components/data/__tests__/responsive-table.fixture.svelte +29 -0
  10. package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts +7 -0
  11. package/dist/components/data/__tests__/responsive-table.fixture.svelte.d.ts.map +1 -0
  12. package/dist/components/data/types.d.ts +8 -0
  13. package/dist/components/data/types.d.ts.map +1 -1
  14. package/dist/components/display/DateDisplay.svelte +76 -14
  15. package/dist/components/display/DateDisplay.svelte.d.ts +2 -0
  16. package/dist/components/display/DateDisplay.svelte.d.ts.map +1 -1
  17. package/dist/components/display/Icon.svelte +3 -16
  18. package/dist/components/display/Icon.svelte.d.ts +0 -9
  19. package/dist/components/display/Icon.svelte.d.ts.map +1 -1
  20. package/dist/components/display/StatusBadge.svelte +29 -2
  21. package/dist/components/display/StatusBadge.svelte.d.ts +4 -2
  22. package/dist/components/display/StatusBadge.svelte.d.ts.map +1 -1
  23. package/dist/components/display/__tests__/DateDisplay.test.js +82 -1
  24. package/dist/components/display/__tests__/Icon.test.js +132 -2
  25. package/dist/components/display/__tests__/StatusBadge.test.js +78 -0
  26. package/dist/components/display/icons.svelte.d.ts +11 -0
  27. package/dist/components/display/icons.svelte.d.ts.map +1 -0
  28. package/dist/components/display/icons.svelte.js +60 -0
  29. package/dist/components/display/index.d.ts +3 -1
  30. package/dist/components/display/index.d.ts.map +1 -1
  31. package/dist/components/display/index.js +1 -0
  32. package/dist/components/display/types.d.ts +2 -0
  33. package/dist/components/display/types.d.ts.map +1 -1
  34. package/dist/components/feedback/ConfirmDialog.svelte +59 -48
  35. package/dist/components/feedback/ConfirmDialog.svelte.d.ts +14 -2
  36. package/dist/components/feedback/ConfirmDialog.svelte.d.ts.map +1 -1
  37. package/dist/components/feedback/ToastViewport.svelte +156 -8
  38. package/dist/components/feedback/ToastViewport.svelte.d.ts +5 -1
  39. package/dist/components/feedback/ToastViewport.svelte.d.ts.map +1 -1
  40. package/dist/components/feedback/__tests__/ConfirmDialog.test.js +38 -20
  41. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte +9 -0
  42. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts +19 -0
  43. package/dist/components/feedback/__tests__/confirm-message.fixture.svelte.d.ts.map +1 -0
  44. package/dist/components/feedback/__tests__/toast.test.js +142 -1
  45. package/dist/components/feedback/index.d.ts +1 -1
  46. package/dist/components/feedback/index.d.ts.map +1 -1
  47. package/dist/components/feedback/toast.d.ts +13 -1
  48. package/dist/components/feedback/toast.d.ts.map +1 -1
  49. package/dist/components/feedback/toast.js +51 -10
  50. package/dist/components/forms/CameraCapture.svelte +619 -0
  51. package/dist/components/forms/CameraCapture.svelte.d.ts +42 -0
  52. package/dist/components/forms/CameraCapture.svelte.d.ts.map +1 -0
  53. package/dist/components/forms/Checkbox.svelte +8 -1
  54. package/dist/components/forms/Checkbox.svelte.d.ts +2 -0
  55. package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
  56. package/dist/components/forms/Combobox.svelte +40 -0
  57. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  58. package/dist/components/forms/Form.svelte +5 -0
  59. package/dist/components/forms/Form.svelte.d.ts +1 -0
  60. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  61. package/dist/components/forms/Input.svelte +8 -1
  62. package/dist/components/forms/Input.svelte.d.ts +2 -0
  63. package/dist/components/forms/Input.svelte.d.ts.map +1 -1
  64. package/dist/components/forms/Listbox.svelte +39 -1
  65. package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
  66. package/dist/components/forms/MultiSelect.svelte +41 -1
  67. package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
  68. package/dist/components/forms/Radio.svelte +1 -0
  69. package/dist/components/forms/RadioGroup.svelte +7 -1
  70. package/dist/components/forms/RadioGroup.svelte.d.ts +2 -0
  71. package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
  72. package/dist/components/forms/SegmentedControl.svelte +152 -11
  73. package/dist/components/forms/SegmentedControl.svelte.d.ts +4 -0
  74. package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
  75. package/dist/components/forms/Select.svelte +8 -1
  76. package/dist/components/forms/Select.svelte.d.ts +2 -0
  77. package/dist/components/forms/Select.svelte.d.ts.map +1 -1
  78. package/dist/components/forms/SignaturePad.svelte +436 -0
  79. package/dist/components/forms/SignaturePad.svelte.d.ts +32 -0
  80. package/dist/components/forms/SignaturePad.svelte.d.ts.map +1 -0
  81. package/dist/components/forms/Switch.svelte +8 -1
  82. package/dist/components/forms/Switch.svelte.d.ts +2 -0
  83. package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
  84. package/dist/components/forms/Textarea.svelte +8 -1
  85. package/dist/components/forms/Textarea.svelte.d.ts +2 -0
  86. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
  87. package/dist/components/forms/__tests__/CameraCapture.test.js +504 -0
  88. package/dist/components/forms/__tests__/Combobox.test.js +279 -12
  89. package/dist/components/forms/__tests__/Form.test.js +46 -0
  90. package/dist/components/forms/__tests__/Listbox.test.js +303 -0
  91. package/dist/components/forms/__tests__/MultiSelect.test.js +281 -0
  92. package/dist/components/forms/__tests__/SignaturePad.test.js +338 -0
  93. package/dist/components/forms/__tests__/camera-capture-session.test.js +207 -0
  94. package/dist/components/forms/__tests__/capture-form-retry.test.js +166 -0
  95. package/dist/components/forms/__tests__/capture-form.fixture.svelte +38 -0
  96. package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts +10 -0
  97. package/dist/components/forms/__tests__/capture-form.fixture.svelte.d.ts.map +1 -0
  98. package/dist/components/forms/__tests__/capture-test-env.d.ts +70 -0
  99. package/dist/components/forms/__tests__/capture-test-env.d.ts.map +1 -0
  100. package/dist/components/forms/__tests__/capture-test-env.js +122 -0
  101. package/dist/components/forms/__tests__/capture-touch.fixture.svelte +15 -0
  102. package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts +8 -0
  103. package/dist/components/forms/__tests__/capture-touch.fixture.svelte.d.ts.map +1 -0
  104. package/dist/components/forms/__tests__/combobox-form.fixture.svelte +29 -0
  105. package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts +14 -0
  106. package/dist/components/forms/__tests__/combobox-form.fixture.svelte.d.ts.map +1 -0
  107. package/dist/components/forms/__tests__/form-attachment.fixture.svelte +28 -0
  108. package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts +11 -0
  109. package/dist/components/forms/__tests__/form-attachment.fixture.svelte.d.ts.map +1 -0
  110. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte +31 -0
  111. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts +13 -0
  112. package/dist/components/forms/__tests__/form-retry-form.fixture.svelte.d.ts.map +1 -0
  113. package/dist/components/forms/__tests__/form-retry-form.test.js +107 -0
  114. package/dist/components/forms/__tests__/hidden-controls-form-retry.test.js +69 -0
  115. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte +29 -0
  116. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts +9 -0
  117. package/dist/components/forms/__tests__/hidden-controls-form.fixture.svelte.d.ts.map +1 -0
  118. package/dist/components/forms/__tests__/listbox-form.fixture.svelte +27 -0
  119. package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts +13 -0
  120. package/dist/components/forms/__tests__/listbox-form.fixture.svelte.d.ts.map +1 -0
  121. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte +27 -0
  122. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts +13 -0
  123. package/dist/components/forms/__tests__/multiselect-form.fixture.svelte.d.ts.map +1 -0
  124. package/dist/components/forms/__tests__/native-file-field.test.js +177 -0
  125. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte +28 -0
  126. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts +12 -0
  127. package/dist/components/forms/__tests__/segmented-posting.fixture.svelte.d.ts.map +1 -0
  128. package/dist/components/forms/__tests__/segmented-posting.test.js +92 -0
  129. package/dist/components/forms/__tests__/signature-pad-logic.test.js +54 -0
  130. package/dist/components/forms/__tests__/touch-density.fixture.svelte +34 -0
  131. package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts +8 -0
  132. package/dist/components/forms/__tests__/touch-density.fixture.svelte.d.ts.map +1 -0
  133. package/dist/components/forms/__tests__/touch-density.test.js +66 -0
  134. package/dist/components/forms/camera-capture-session.d.ts +81 -0
  135. package/dist/components/forms/camera-capture-session.d.ts.map +1 -0
  136. package/dist/components/forms/camera-capture-session.js +130 -0
  137. package/dist/components/forms/form-retry/__tests__/boundaries.test.js +23 -0
  138. package/dist/components/forms/form-retry/__tests__/content-digest.test.js +70 -0
  139. package/dist/components/forms/form-retry/__tests__/controller.test.js +802 -0
  140. package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts +82 -0
  141. package/dist/components/forms/form-retry/__tests__/fake-kit.d.ts.map +1 -0
  142. package/dist/components/forms/form-retry/__tests__/fake-kit.js +180 -0
  143. package/dist/components/forms/form-retry/__tests__/form-draft.test.js +169 -0
  144. package/dist/components/forms/form-retry/__tests__/ssr.test.js +37 -0
  145. package/dist/components/forms/form-retry/__tests__/storage.d.ts +26 -0
  146. package/dist/components/forms/form-retry/__tests__/storage.d.ts.map +1 -0
  147. package/dist/components/forms/form-retry/__tests__/storage.js +63 -0
  148. package/dist/components/forms/form-retry/__tests__/submission-key.test.js +236 -0
  149. package/dist/components/forms/form-retry/__tests__/submitted-draft.test.js +334 -0
  150. package/dist/components/forms/form-retry/content-digest.d.ts +54 -0
  151. package/dist/components/forms/form-retry/content-digest.d.ts.map +1 -0
  152. package/dist/components/forms/form-retry/content-digest.js +169 -0
  153. package/dist/components/forms/form-retry/controller.d.ts +214 -0
  154. package/dist/components/forms/form-retry/controller.d.ts.map +1 -0
  155. package/dist/components/forms/form-retry/controller.js +404 -0
  156. package/dist/components/forms/form-retry/form-draft.d.ts +68 -0
  157. package/dist/components/forms/form-retry/form-draft.d.ts.map +1 -0
  158. package/dist/components/forms/form-retry/form-draft.js +161 -0
  159. package/dist/components/forms/form-retry/index.d.ts +19 -0
  160. package/dist/components/forms/form-retry/index.d.ts.map +1 -0
  161. package/dist/components/forms/form-retry/index.js +16 -0
  162. package/dist/components/forms/form-retry/submission-key.d.ts +244 -0
  163. package/dist/components/forms/form-retry/submission-key.d.ts.map +1 -0
  164. package/dist/components/forms/form-retry/submission-key.js +342 -0
  165. package/dist/components/forms/form-retry/submitted-draft.d.ts +112 -0
  166. package/dist/components/forms/form-retry/submitted-draft.d.ts.map +1 -0
  167. package/dist/components/forms/form-retry/submitted-draft.js +270 -0
  168. package/dist/components/forms/form-retry/types.d.ts +84 -0
  169. package/dist/components/forms/form-retry/types.d.ts.map +1 -0
  170. package/dist/components/forms/form-retry/types.js +13 -0
  171. package/dist/components/forms/index.d.ts +7 -1
  172. package/dist/components/forms/index.d.ts.map +1 -1
  173. package/dist/components/forms/index.js +8 -0
  174. package/dist/components/forms/native-file-field.d.ts +94 -0
  175. package/dist/components/forms/native-file-field.d.ts.map +1 -0
  176. package/dist/components/forms/native-file-field.js +147 -0
  177. package/dist/components/forms/signature-pad-logic.d.ts +50 -0
  178. package/dist/components/forms/signature-pad-logic.d.ts.map +1 -0
  179. package/dist/components/forms/signature-pad-logic.js +48 -0
  180. package/dist/components/forms/types.d.ts +41 -0
  181. package/dist/components/forms/types.d.ts.map +1 -1
  182. package/dist/components/nav/FilterChips.svelte +8 -1
  183. package/dist/components/nav/FilterChips.svelte.d.ts +2 -0
  184. package/dist/components/nav/FilterChips.svelte.d.ts.map +1 -1
  185. package/dist/components/ui/Button.svelte +9 -2
  186. package/dist/components/ui/Button.svelte.d.ts +2 -0
  187. package/dist/components/ui/Button.svelte.d.ts.map +1 -1
  188. package/dist/i18n/strings.ui.d.ts +24 -0
  189. package/dist/i18n/strings.ui.d.ts.map +1 -1
  190. package/dist/i18n/strings.ui.js +26 -0
  191. package/dist/styles/tokens.css +2 -0
  192. package/dist/svelte/__tests__/playground.test.js +2 -1
  193. package/dist/svelte/playground/CapturePreview.svelte +56 -0
  194. package/dist/svelte/playground/CapturePreview.svelte.d.ts +4 -0
  195. package/dist/svelte/playground/CapturePreview.svelte.d.ts.map +1 -0
  196. package/dist/svelte/playground.d.ts.map +1 -1
  197. package/dist/svelte/playground.js +10 -0
  198. package/dist/themes/ThemeProvider.svelte +9 -0
  199. package/dist/themes/ThemeProvider.svelte.d.ts +2 -0
  200. package/dist/themes/ThemeProvider.svelte.d.ts.map +1 -1
  201. package/dist/themes/css-generator.d.ts.map +1 -1
  202. package/dist/themes/css-generator.js +3 -0
  203. package/dist/themes/styles/glass.css +4 -0
  204. package/dist/themes/styles/happyvertical.css +4 -0
  205. package/dist/themes/styles/material.css +4 -0
  206. package/dist/themes/styles/smrt.css +4 -0
  207. package/dist/themes/styles/studio.css +4 -0
  208. package/package.json +8 -2
@@ -0,0 +1,619 @@
1
+ <!--
2
+ Live camera capture through `getUserMedia`, for photos taken inside a form.
3
+
4
+ The component owns the whole flow: request the camera, preview the live
5
+ stream, capture a still, and let the user review and retake before the photo
6
+ is committed. A committed photo leaves through `onCapture({ blob, dataUrl })`
7
+ and, when `name` is set, is posted by a plain native form as a file field
8
+ (`native-file-field.ts` explains the DataTransfer and `formdata`-event
9
+ strategies). A reset of the owning form discards the photo, as it empties a
10
+ native file input. Permission denied, no camera, unsupported browser and
11
+ other errors are distinct rendered states.
12
+
13
+ The request/classify/release lifecycle lives in the framework-free
14
+ `camera-capture-session.ts`. The `$effect` below depends only on `disabled`
15
+ and `facingMode`, never on the rendered state: the state is written from the
16
+ session's async continuation, so reading it there would tear the stream down
17
+ and restart it in a loop. Its cleanup stops every track on `disabled` and on
18
+ unmount alike, so the camera light never stays on.
19
+
20
+ `<input type="file" capture>` is NOT the default path: it hands control to the
21
+ OS picker (no live preview, gallery access, no in-app review). It is available
22
+ only through the opt-in `fileInputFallback`, and only for browsers without
23
+ `getUserMedia`.
24
+
25
+ Ported from teamworks-os `CameraCapture.svelte`, smrt#3290.
26
+ -->
27
+ <script lang="ts">
28
+ import { onMount, untrack } from 'svelte';
29
+ import { M, useI18n } from '../../i18n/index.js';
30
+ import {
31
+ type CameraFacingMode,
32
+ createCameraSession,
33
+ } from './camera-capture-session.js';
34
+ import {
35
+ focusControl,
36
+ highlightControl,
37
+ revealControl,
38
+ } from './control-dom.js';
39
+ import type { ControlInteractionOptions } from './control-interaction.js';
40
+ import { tryGetControlInteractionContext } from './control-interaction-context.js';
41
+ import {
42
+ assignFileWithDataTransfer,
43
+ attachFormDataFallback,
44
+ attachFormResetListener,
45
+ clearFileInput,
46
+ detectNativeFileFieldStrategy,
47
+ fileNameForType,
48
+ type NativeFileFieldStrategy,
49
+ } from './native-file-field.js';
50
+ import type {
51
+ CameraCaptureLabels,
52
+ CameraCaptureState,
53
+ CaptureResult,
54
+ } from './types.js';
55
+ import { useControlRegistration } from './use-control-registration.svelte.js';
56
+
57
+ export interface Props {
58
+ /** Which camera to request. Re-requested on change while live. */
59
+ facingMode?: CameraFacingMode;
60
+ /**
61
+ * Stops the camera and disables every action; a held photo is kept and,
62
+ * when committed, still posts with the form.
63
+ */
64
+ disabled?: boolean;
65
+ /** Called when the user commits a photo with "Use photo". */
66
+ onCapture?: (result: CaptureResult) => void;
67
+ /** Called when a committed photo is discarded by "Retake" or a form reset. */
68
+ onClear?: () => void;
69
+ /** Field name: the committed photo posts as a file in a native form. */
70
+ name?: string;
71
+ /** Posted file name. Defaults to `photo.<ext>` for the image type. */
72
+ fileName?: string;
73
+ /** Encoded image type. */
74
+ imageType?: 'image/jpeg' | 'image/png' | 'image/webp';
75
+ /** Encoder quality for lossy types, 0 to 1. */
76
+ quality?: number;
77
+ /**
78
+ * Opt in to `<input type="file" capture>` for browsers without
79
+ * `getUserMedia`. Off by default: without it those browsers render the
80
+ * unsupported state.
81
+ */
82
+ fileInputFallback?: boolean;
83
+ /** Text overrides; defaults come from the `ui` i18n catalog. */
84
+ labels?: Partial<CameraCaptureLabels>;
85
+ /** DOM id of the component root. */
86
+ id?: string;
87
+ /** CSS class to apply to the component root. */
88
+ class?: string;
89
+ /** Interaction options or false to disable registration. */
90
+ interaction?: ControlInteractionOptions | false;
91
+ }
92
+
93
+ let {
94
+ facingMode = 'environment',
95
+ disabled = false,
96
+ onCapture,
97
+ onClear,
98
+ name,
99
+ fileName,
100
+ imageType = 'image/jpeg',
101
+ quality = 0.92,
102
+ fileInputFallback = false,
103
+ labels,
104
+ id,
105
+ class: className = '',
106
+ interaction,
107
+ }: Props = $props();
108
+
109
+ const { t } = useI18n();
110
+ const text = $derived<CameraCaptureLabels>({
111
+ region: t(M['ui.camera_capture.region']),
112
+ preview: t(M['ui.camera_capture.preview']),
113
+ starting: t(M['ui.camera_capture.starting']),
114
+ off: t(M['ui.camera_capture.off']),
115
+ unsupported: t(M['ui.camera_capture.unsupported']),
116
+ permissionDenied: t(M['ui.camera_capture.permission_denied']),
117
+ noCamera: t(M['ui.camera_capture.no_camera']),
118
+ error: t(M['ui.camera_capture.error']),
119
+ retry: t(M['ui.camera_capture.retry']),
120
+ capture: t(M['ui.camera_capture.capture']),
121
+ retake: t(M['ui.camera_capture.retake']),
122
+ usePhoto: t(M['ui.camera_capture.use_photo']),
123
+ reviewAlt: t(M['ui.camera_capture.review_alt']),
124
+ committedAlt: t(M['ui.camera_capture.committed_alt']),
125
+ committed: t(M['ui.camera_capture.committed']),
126
+ choosePhoto: t(M['ui.camera_capture.choose_photo']),
127
+ ...labels,
128
+ });
129
+
130
+ const instanceId = $props.id();
131
+ const interactionContext = tryGetControlInteractionContext();
132
+ const resolvedId = $derived(id ?? `smrt-camera-${instanceId}`);
133
+ const controlId = $derived(
134
+ interaction === false ? undefined : (interaction?.id ?? name ?? resolvedId),
135
+ );
136
+ const resolvedFileName = $derived(
137
+ fileName ?? fileNameForType('photo', imageType),
138
+ );
139
+
140
+ let cameraState = $state<CameraCaptureState>(
141
+ untrack(() => disabled) ? 'off' : 'starting',
142
+ );
143
+ let previewUrl = $state('');
144
+ let unsupportedApi = $state(false);
145
+ let strategy = $state<NativeFileFieldStrategy>('data-transfer');
146
+ let rootEl = $state<HTMLDivElement | null>(null);
147
+ let videoEl = $state<HTMLVideoElement | null>(null);
148
+ let fieldEl = $state<HTMLInputElement | null>(null);
149
+ let pickerEl = $state<HTMLInputElement | null>(null);
150
+ let pending: CaptureResult | null = null;
151
+ let committedFile: File | null = null;
152
+ let fallbackReads = 0;
153
+ // Set while a captured frame encodes; a form reset supersedes the request.
154
+ let capturing = false;
155
+ let captureRequest = 0;
156
+
157
+ const fallbackActive = $derived(unsupportedApi && fileInputFallback);
158
+ const message = $derived.by(() => {
159
+ switch (cameraState) {
160
+ case 'off':
161
+ return text.off;
162
+ case 'starting':
163
+ return text.starting;
164
+ case 'unsupported':
165
+ return text.unsupported;
166
+ case 'permission-denied':
167
+ return text.permissionDenied;
168
+ case 'no-camera':
169
+ return text.noCamera;
170
+ case 'error':
171
+ return text.error;
172
+ case 'committed':
173
+ return text.committed;
174
+ default:
175
+ return '';
176
+ }
177
+ });
178
+ const isProblem = $derived(
179
+ cameraState === 'unsupported' ||
180
+ cameraState === 'permission-denied' ||
181
+ cameraState === 'no-camera' ||
182
+ cameraState === 'error',
183
+ );
184
+
185
+ const session = createCameraSession({
186
+ mediaDevices:
187
+ typeof navigator === 'undefined' ? undefined : navigator.mediaDevices,
188
+ });
189
+
190
+ function holdsPhoto(): boolean {
191
+ return cameraState === 'reviewing' || cameraState === 'committed';
192
+ }
193
+
194
+ async function startCamera(): Promise<void> {
195
+ cameraState = 'starting';
196
+ const result = await session.start(facingMode);
197
+ if (!result.ok) {
198
+ // `cancelled`: a stop() (unmount, `disabled`, a newer start) superseded
199
+ // this request and the session already released what it acquired.
200
+ if (result.kind === 'cancelled') return;
201
+ if (result.kind === 'unsupported') {
202
+ unsupportedApi = true;
203
+ cameraState = fileInputFallback ? 'fallback' : 'unsupported';
204
+ return;
205
+ }
206
+ cameraState = result.kind;
207
+ return;
208
+ }
209
+ const stream = result.stream;
210
+ if (videoEl) {
211
+ videoEl.srcObject = stream;
212
+ try {
213
+ await videoEl.play();
214
+ } catch {
215
+ // Autoplay policy (or a test DOM) can reject play(); the stream is live.
216
+ }
217
+ }
218
+ // A stop() during play() released this stream; do not resurrect the state.
219
+ if (session.stream !== stream) return;
220
+ cameraState = 'streaming';
221
+ }
222
+
223
+ function stopStream(): void {
224
+ session.stop();
225
+ if (videoEl) videoEl.srcObject = null;
226
+ }
227
+
228
+ $effect(() => {
229
+ const off = disabled;
230
+ void facingMode; // tracked: a new facing mode re-requests the camera
231
+ if (off) {
232
+ untrack(() => {
233
+ if (!holdsPhoto() && !unsupportedApi) cameraState = 'off';
234
+ });
235
+ return;
236
+ }
237
+ untrack(() => {
238
+ if (!holdsPhoto() && !unsupportedApi) void startCamera();
239
+ });
240
+ return () => stopStream();
241
+ });
242
+
243
+ onMount(() => {
244
+ strategy = detectNativeFileFieldStrategy();
245
+ });
246
+
247
+ $effect(() => {
248
+ if (strategy !== 'formdata-event' || typeof document === 'undefined') return;
249
+ return attachFormDataFallback(document, () =>
250
+ name && fieldEl && !fallbackActive
251
+ ? { form: fieldEl.form, name, file: committedFile, source: fieldEl }
252
+ : null,
253
+ );
254
+ });
255
+
256
+ function writeField(file: File | null): void {
257
+ if (!fieldEl || !name || strategy !== 'data-transfer') return;
258
+ if (!file) {
259
+ clearFileInput(fieldEl);
260
+ return;
261
+ }
262
+ if (!assignFileWithDataTransfer(fieldEl, file)) {
263
+ // The probe passed but this engine refused the real assignment: drop to
264
+ // the next strategy, which reads `committedFile` at submit time.
265
+ strategy = detectNativeFileFieldStrategy({
266
+ FormDataEvent: (globalThis as Record<string, unknown>).FormDataEvent,
267
+ });
268
+ }
269
+ }
270
+
271
+ function capture(): void {
272
+ const video = videoEl;
273
+ if (disabled || !video || !session.stream) return;
274
+ const canvas = document.createElement('canvas');
275
+ canvas.width = video.videoWidth || 640;
276
+ canvas.height = video.videoHeight || 480;
277
+ const context = canvas.getContext('2d');
278
+ if (!context) {
279
+ stopStream();
280
+ cameraState = 'error';
281
+ return;
282
+ }
283
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
284
+ const dataUrl = canvas.toDataURL(imageType, quality);
285
+ // The frame is on the canvas: release the camera now, not after encoding.
286
+ stopStream();
287
+ capturing = true;
288
+ const request = ++captureRequest;
289
+ canvas.toBlob(
290
+ (blob) => {
291
+ // A form reset during encoding superseded this frame.
292
+ if (request !== captureRequest) return;
293
+ capturing = false;
294
+ if (!blob) {
295
+ cameraState = 'error';
296
+ return;
297
+ }
298
+ pending = { blob, dataUrl };
299
+ previewUrl = dataUrl;
300
+ cameraState = 'reviewing';
301
+ },
302
+ imageType,
303
+ quality,
304
+ );
305
+ }
306
+
307
+ function usePhoto(): void {
308
+ if (disabled || !pending) return;
309
+ const result = pending;
310
+ committedFile = new File([result.blob], resolvedFileName, {
311
+ type: result.blob.type || imageType,
312
+ });
313
+ writeField(committedFile);
314
+ cameraState = 'committed';
315
+ onCapture?.(result);
316
+ }
317
+
318
+ function retake(): void {
319
+ if (disabled) return;
320
+ const wasCommitted = cameraState === 'committed';
321
+ pending = null;
322
+ previewUrl = '';
323
+ if (wasCommitted) {
324
+ committedFile = null;
325
+ writeField(null);
326
+ onClear?.();
327
+ }
328
+ void startCamera();
329
+ }
330
+
331
+ /**
332
+ * The owning form reset. A native file input is emptied by its form's reset,
333
+ * so the photo goes too, committed or still under review (a stale review would
334
+ * invite attaching the previous entry's photo to the next one), and the
335
+ * component returns to its initial flow: the live camera, `off` while
336
+ * `disabled`, or the emptied fallback picker. It
337
+ * runs even while `disabled`, as a reset empties a disabled native input. With
338
+ * nothing held a reset changes nothing, so it never re-prompts for a camera.
339
+ */
340
+ function resetCapture(): void {
341
+ const wasCommitted = cameraState === 'committed';
342
+ const wasCapturing = capturing;
343
+ captureRequest += 1;
344
+ capturing = false;
345
+ // Empty the fallback picker as the browser's reset will (the same either
346
+ // way, and still so if a later listener cancels the reset), and drop a read
347
+ // still in flight.
348
+ fallbackReads += 1;
349
+ if (pickerEl) clearFileInput(pickerEl);
350
+ if (!holdsPhoto() && !wasCapturing) return;
351
+ pending = null;
352
+ previewUrl = '';
353
+ if (wasCommitted) {
354
+ committedFile = null;
355
+ writeField(null);
356
+ }
357
+ if (fallbackActive) cameraState = 'fallback';
358
+ else if (disabled) cameraState = 'off';
359
+ else void startCamera();
360
+ if (wasCommitted) onClear?.();
361
+ }
362
+
363
+ $effect(() => {
364
+ if (typeof document === 'undefined') return;
365
+ return attachFormResetListener(
366
+ document,
367
+ () => fieldEl?.form ?? pickerEl?.form ?? rootEl?.closest('form'),
368
+ resetCapture,
369
+ );
370
+ });
371
+
372
+ function readDataUrl(file: Blob): Promise<string> {
373
+ return new Promise((resolve, reject) => {
374
+ const reader = new FileReader();
375
+ reader.onload = () => resolve(String(reader.result ?? ''));
376
+ reader.onerror = () => reject(reader.error);
377
+ reader.readAsDataURL(file);
378
+ });
379
+ }
380
+
381
+ /**
382
+ * The fallback picker is the field that posts, so `disabled` must not set the
383
+ * native attribute: a disabled control is left out of the submission, and a
384
+ * committed photo still on screen would silently stop posting. Interaction is
385
+ * blocked here instead (a label click and keyboard activation both arrive as
386
+ * a click on the input), with `aria-disabled` and no tab stop.
387
+ */
388
+ function blockPickerWhileDisabled(event: MouseEvent): void {
389
+ if (disabled) event.preventDefault();
390
+ }
391
+
392
+ async function handleFallbackChange(
393
+ event: Event & { currentTarget: HTMLInputElement },
394
+ ): Promise<void> {
395
+ const file = event.currentTarget.files?.[0] ?? null;
396
+ const read = ++fallbackReads;
397
+ if (!file) {
398
+ if (cameraState === 'committed') {
399
+ committedFile = null;
400
+ previewUrl = '';
401
+ cameraState = 'fallback';
402
+ onClear?.();
403
+ }
404
+ return;
405
+ }
406
+ let dataUrl = '';
407
+ try {
408
+ dataUrl = await readDataUrl(file);
409
+ } catch {
410
+ // A preview is a convenience; the chosen file still posts and is reported.
411
+ }
412
+ if (read !== fallbackReads) return;
413
+ committedFile = file;
414
+ previewUrl = dataUrl;
415
+ cameraState = 'committed';
416
+ onCapture?.({ blob: file, dataUrl });
417
+ }
418
+
419
+ useControlRegistration(() => {
420
+ const root = rootEl;
421
+ if (!root || interaction === false) return false;
422
+ return {
423
+ controlId,
424
+ subject: interaction?.subject,
425
+ metadata: {
426
+ kind: 'file',
427
+ label: text.region,
428
+ description: interaction?.description,
429
+ sensitivity: interaction?.sensitivity ?? 'sensitive',
430
+ readable: false,
431
+ writable: false,
432
+ capabilities: ['focus', 'reveal', 'highlight', 'explain'],
433
+ },
434
+ focus: () => focusControl(root),
435
+ reveal: () => revealControl(root),
436
+ highlight: (durationMs) => highlightControl(root, durationMs),
437
+ getState: () => ({ disabled }),
438
+ };
439
+ });
440
+ </script>
441
+
442
+ <div
443
+ bind:this={rootEl}
444
+ id={resolvedId}
445
+ class="camera-capture {className}"
446
+ class:disabled
447
+ role="group"
448
+ aria-label={text.region}
449
+ data-state={cameraState}
450
+ data-smrt-file-field={name ? (fallbackActive ? 'file-input' : strategy) : undefined}
451
+ data-smrt-control={controlId}
452
+ data-smrt-form={interactionContext?.formId}
453
+ data-smrt-subject-type={interaction === false ? undefined : interaction?.subject?.type}
454
+ data-smrt-subject-id={interaction === false ? undefined : interaction?.subject?.id}
455
+ >
456
+ <div class="stage" class:empty={cameraState !== 'streaming' && !previewUrl}>
457
+ <video
458
+ bind:this={videoEl}
459
+ class="media"
460
+ class:hidden={cameraState !== 'streaming'}
461
+ aria-label={text.preview}
462
+ autoplay
463
+ playsinline
464
+ muted
465
+ ></video>
466
+ {#if previewUrl && (cameraState === 'reviewing' || cameraState === 'committed')}
467
+ <img
468
+ class="media"
469
+ src={previewUrl}
470
+ alt={cameraState === 'committed' ? text.committedAlt : text.reviewAlt}
471
+ />
472
+ {/if}
473
+ </div>
474
+
475
+ <p class="message" class:problem={isProblem} role="status">{message}</p>
476
+
477
+ <div class="actions">
478
+ {#if cameraState === 'permission-denied' || cameraState === 'no-camera' || cameraState === 'error'}
479
+ <button type="button" class="action" {disabled} onclick={() => void startCamera()}>
480
+ {text.retry}
481
+ </button>
482
+ {:else if cameraState === 'streaming'}
483
+ <button type="button" class="action primary" {disabled} onclick={capture}>
484
+ {text.capture}
485
+ </button>
486
+ {:else if cameraState === 'reviewing'}
487
+ <button type="button" class="action" {disabled} onclick={retake}>{text.retake}</button>
488
+ <button type="button" class="action primary" {disabled} onclick={usePhoto}>
489
+ {text.usePhoto}
490
+ </button>
491
+ {:else if cameraState === 'committed' && !fallbackActive}
492
+ <button type="button" class="action" {disabled} onclick={retake}>{text.retake}</button>
493
+ {/if}
494
+ {#if fallbackActive}
495
+ <label class="action primary picker" class:disabled>
496
+ <input
497
+ bind:this={pickerEl}
498
+ class="visually-hidden"
499
+ type="file"
500
+ accept="image/*"
501
+ capture={facingMode}
502
+ {name}
503
+ aria-disabled={disabled || undefined}
504
+ tabindex={disabled ? -1 : undefined}
505
+ onclick={blockPickerWhileDisabled}
506
+ onchange={handleFallbackChange}
507
+ />
508
+ <span>{cameraState === 'committed' ? text.retake : text.choosePhoto}</span>
509
+ </label>
510
+ {/if}
511
+ </div>
512
+
513
+ {#if name && !fallbackActive}
514
+ <input
515
+ bind:this={fieldEl}
516
+ class="field"
517
+ type="file"
518
+ hidden
519
+ tabindex="-1"
520
+ name={strategy === 'data-transfer' ? name : undefined}
521
+ data-smrt-capture-field
522
+ />
523
+ {/if}
524
+ </div>
525
+
526
+ <style>
527
+ .camera-capture {
528
+ display: grid;
529
+ gap: var(--smrt-spacing-3);
530
+ width: 100%;
531
+ max-width: 40rem;
532
+ color: var(--smrt-color-on-surface);
533
+ }
534
+ .stage {
535
+ position: relative;
536
+ display: grid;
537
+ overflow: hidden;
538
+ aspect-ratio: 4 / 3;
539
+ border-radius: var(--smrt-radius-medium);
540
+ background: var(--smrt-color-scrim);
541
+ }
542
+ .stage.empty {
543
+ background: var(--smrt-color-surface-container);
544
+ }
545
+ .media {
546
+ grid-area: 1 / 1;
547
+ width: 100%;
548
+ height: 100%;
549
+ object-fit: cover;
550
+ }
551
+ .media.hidden,
552
+ .field {
553
+ display: none;
554
+ }
555
+ .message {
556
+ margin: 0;
557
+ font: var(--smrt-typography-body-large-font);
558
+ }
559
+ .message:empty {
560
+ display: none;
561
+ }
562
+ .message.problem {
563
+ color: var(--smrt-color-error);
564
+ }
565
+ .actions {
566
+ display: flex;
567
+ flex-wrap: wrap;
568
+ gap: var(--smrt-spacing-3);
569
+ }
570
+ .actions:empty {
571
+ display: none;
572
+ }
573
+ .action {
574
+ position: relative;
575
+ flex: 1 1 auto;
576
+ display: inline-flex;
577
+ align-items: center;
578
+ justify-content: center;
579
+ /* Capture is a touch flow: its actions are touch targets at every
580
+ density, on the shared scale (smrt#3252). */
581
+ min-width: var(--smrt-touch-target-min, 48px);
582
+ min-height: var(--smrt-touch-target-min, 48px);
583
+ padding: 0 var(--smrt-spacing-4);
584
+ border: 1px solid var(--smrt-color-outline);
585
+ border-radius: var(--smrt-radius-small);
586
+ background: var(--smrt-color-surface);
587
+ color: var(--smrt-color-on-surface);
588
+ font: var(--smrt-typography-label-large-font);
589
+ cursor: pointer;
590
+ }
591
+ .action.primary {
592
+ flex-grow: 2;
593
+ border-color: var(--smrt-color-primary);
594
+ background: var(--smrt-color-primary);
595
+ color: var(--smrt-color-on-primary);
596
+ }
597
+ .action:focus-visible,
598
+ .picker:has(input:focus-visible) {
599
+ outline: 2px solid var(--smrt-color-primary);
600
+ outline-offset: 2px;
601
+ }
602
+ .action:disabled,
603
+ .picker.disabled {
604
+ opacity: 0.5;
605
+ cursor: not-allowed;
606
+ }
607
+ .visually-hidden {
608
+ position: absolute;
609
+ width: 1px;
610
+ height: 1px;
611
+ overflow: hidden;
612
+ clip-path: inset(50%);
613
+ white-space: nowrap;
614
+ }
615
+ :global(.camera-capture[data-smrt-highlighted='true']) {
616
+ outline: 3px solid var(--smrt-color-tertiary);
617
+ outline-offset: 4px;
618
+ }
619
+ </style>
@@ -0,0 +1,42 @@
1
+ import { type CameraFacingMode } from './camera-capture-session.js';
2
+ import type { ControlInteractionOptions } from './control-interaction.js';
3
+ import type { CameraCaptureLabels, CaptureResult } from './types.js';
4
+ export interface Props {
5
+ /** Which camera to request. Re-requested on change while live. */
6
+ facingMode?: CameraFacingMode;
7
+ /**
8
+ * Stops the camera and disables every action; a held photo is kept and,
9
+ * when committed, still posts with the form.
10
+ */
11
+ disabled?: boolean;
12
+ /** Called when the user commits a photo with "Use photo". */
13
+ onCapture?: (result: CaptureResult) => void;
14
+ /** Called when a committed photo is discarded by "Retake" or a form reset. */
15
+ onClear?: () => void;
16
+ /** Field name: the committed photo posts as a file in a native form. */
17
+ name?: string;
18
+ /** Posted file name. Defaults to `photo.<ext>` for the image type. */
19
+ fileName?: string;
20
+ /** Encoded image type. */
21
+ imageType?: 'image/jpeg' | 'image/png' | 'image/webp';
22
+ /** Encoder quality for lossy types, 0 to 1. */
23
+ quality?: number;
24
+ /**
25
+ * Opt in to `<input type="file" capture>` for browsers without
26
+ * `getUserMedia`. Off by default: without it those browsers render the
27
+ * unsupported state.
28
+ */
29
+ fileInputFallback?: boolean;
30
+ /** Text overrides; defaults come from the `ui` i18n catalog. */
31
+ labels?: Partial<CameraCaptureLabels>;
32
+ /** DOM id of the component root. */
33
+ id?: string;
34
+ /** CSS class to apply to the component root. */
35
+ class?: string;
36
+ /** Interaction options or false to disable registration. */
37
+ interaction?: ControlInteractionOptions | false;
38
+ }
39
+ declare const CameraCapture: import("svelte").Component<Props, {}, "">;
40
+ type CameraCapture = ReturnType<typeof CameraCapture>;
41
+ export default CameraCapture;
42
+ //# sourceMappingURL=CameraCapture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CameraCapture.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CameraCapture.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,6BAA6B,CAAC;AAMrC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAW1E,OAAO,KAAK,EACV,mBAAmB,EAEnB,aAAa,EACd,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,KAAK;IACpB,kEAAkE;IAClE,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IAC5C,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,SAAS,CAAC,EAAE,YAAY,GAAG,WAAW,GAAG,YAAY,CAAC;IACtD,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACtC,oCAAoC;IACpC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;CACjD;AAuZD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -17,6 +17,8 @@ import { useControlRegistration } from './use-control-registration.svelte.js';
17
17
 
18
18
  export interface Props
19
19
  extends Omit<HTMLInputAttributes, 'type' | 'class' | 'checked'> {
20
+ /** Target density; omit to inherit the ThemeProvider setting. */
21
+ density?: 'comfortable' | 'touch';
20
22
  /** Whether the checkbox is checked (bindable). */
21
23
  checked?: boolean;
22
24
  /** Whether the checkbox shows indeterminate state. */
@@ -43,6 +45,7 @@ let {
43
45
  onchange,
44
46
  'aria-label': ariaLabel,
45
47
  'aria-describedby': ariaDescribedby,
48
+ density,
46
49
  ...rest
47
50
  }: Props = $props();
48
51
 
@@ -121,7 +124,7 @@ useControlRegistration(() => {
121
124
  </script>
122
125
 
123
126
  <label
124
- class="checkbox {className}"
127
+ data-density={density} class="checkbox {className}"
125
128
  class:checkbox--disabled={disabled}
126
129
  data-smrt-hit-target
127
130
  data-smrt-control={controlId}
@@ -184,4 +187,8 @@ useControlRegistration(() => {
184
187
  :global(:is(td, th):has(> [data-smrt-hit-target]:only-child)) > .checkbox::before { inset: 0; inline-size: auto; block-size: auto; margin: 0; }
185
188
 
186
189
  @media (prefers-reduced-motion: reduce) { .checkbox__box { transition: none; } }
190
+
191
+ .checkbox[data-density='touch'] { --smrt-control-target-min: var(--smrt-touch-target-min, 48px); }
192
+ .checkbox[data-density='comfortable'] { --smrt-control-target-min: 0px; }
193
+ .checkbox { min-height: var(--smrt-control-target-min, 0px); min-width: var(--smrt-control-target-min, 0px); }
187
194
  </style>