@happyvertical/smrt-svelte 0.51.39 → 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 (143) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +214 -1
  3. package/agents/components.md +8 -1
  4. package/agents/workspace.md +24 -0
  5. package/dist/Provider.svelte +19 -15
  6. package/dist/Provider.svelte.d.ts +2 -2
  7. package/dist/Provider.svelte.d.ts.map +1 -1
  8. package/dist/__tests__/provider-mode-harness.svelte +14 -0
  9. package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
  10. package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
  11. package/dist/__tests__/provider-mode-probe.svelte +13 -0
  12. package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
  13. package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
  14. package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
  15. package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
  16. package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
  17. package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
  18. package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
  19. package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
  20. package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
  21. package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
  22. package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
  23. package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
  24. package/dist/browser-ai/adapters/stt/index.js +2 -1
  25. package/dist/components/forms/FileUpload.svelte +120 -16
  26. package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
  27. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/Form.svelte +118 -8
  29. package/dist/components/forms/Form.svelte.d.ts +18 -2
  30. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/FormScope.svelte +202 -0
  32. package/dist/components/forms/FormScope.svelte.d.ts +22 -0
  33. package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
  34. package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
  35. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
  36. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
  37. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
  38. package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
  39. package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
  40. package/dist/components/forms/__tests__/FormScope.test.js +39 -0
  41. package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
  42. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
  43. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
  44. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
  45. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
  46. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
  47. package/dist/components/forms/index.d.ts +1 -0
  48. package/dist/components/forms/index.d.ts.map +1 -1
  49. package/dist/components/forms/index.js +1 -0
  50. package/dist/components/workspace/README.md +11 -0
  51. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +597 -0
  52. package/dist/components/workspace/__tests__/AdminShell.test.js +257 -1
  53. package/dist/components/workspace/__tests__/TenantNav.test.js +115 -0
  54. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
  55. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
  56. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
  57. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
  58. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
  59. package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
  60. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
  61. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
  62. package/dist/components/workspace/admin-shell/AdminShell.svelte +1099 -59
  63. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +44 -1
  64. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  65. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
  66. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
  67. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
  68. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
  69. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
  70. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
  71. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
  72. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
  73. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
  74. package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
  75. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
  76. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
  77. package/dist/components/workspace/admin-shell/TenantNav.svelte +116 -13
  78. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
  79. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  80. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
  81. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
  82. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
  83. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
  84. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
  85. package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
  86. package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
  87. package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
  88. package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
  89. package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
  90. package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
  91. package/dist/components/workspace/admin-shell/settings.js +84 -2
  92. package/dist/components/workspace/admin-shell/state.svelte.d.ts +57 -1
  93. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  94. package/dist/components/workspace/admin-shell/state.svelte.js +156 -6
  95. package/dist/components/workspace/admin-shell/types.d.ts +115 -1
  96. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  97. package/dist/components/workspace/admin-shell/types.js +13 -0
  98. package/dist/components/workspace/index.d.ts +11 -4
  99. package/dist/components/workspace/index.d.ts.map +1 -1
  100. package/dist/components/workspace/index.js +12 -3
  101. package/dist/i18n/strings.forms.d.ts +3 -0
  102. package/dist/i18n/strings.forms.d.ts.map +1 -1
  103. package/dist/i18n/strings.forms.js +3 -0
  104. package/dist/i18n/strings.workspace.d.ts +14 -0
  105. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  106. package/dist/i18n/strings.workspace.js +14 -0
  107. package/dist/index.d.ts +1 -1
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +1 -1
  110. package/dist/provider-mode.test.js +86 -0
  111. package/dist/state/app-state.svelte.d.ts.map +1 -1
  112. package/dist/state/app-state.svelte.js +8 -8
  113. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
  114. package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
  115. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
  116. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
  117. package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
  118. package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
  119. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
  120. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
  121. package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
  122. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
  123. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
  124. package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
  125. package/dist/web/__tests__/webmcp-ui.test.js +18 -0
  126. package/dist/web/index.d.ts +4 -1
  127. package/dist/web/index.d.ts.map +1 -1
  128. package/dist/web/index.js +4 -1
  129. package/dist/web/link-surface.svelte.d.ts +14 -0
  130. package/dist/web/link-surface.svelte.d.ts.map +1 -0
  131. package/dist/web/link-surface.svelte.js +49 -0
  132. package/dist/web/list-surface.svelte.d.ts +80 -0
  133. package/dist/web/list-surface.svelte.d.ts.map +1 -0
  134. package/dist/web/list-surface.svelte.js +309 -0
  135. package/dist/web/step-surface.svelte.d.ts +17 -0
  136. package/dist/web/step-surface.svelte.d.ts.map +1 -0
  137. package/dist/web/step-surface.svelte.js +56 -0
  138. package/dist/web/webmcp-ui-context.d.ts +7 -0
  139. package/dist/web/webmcp-ui-context.d.ts.map +1 -1
  140. package/dist/web/webmcp-ui-context.js +10 -0
  141. package/dist/web/webmcp-ui.d.ts.map +1 -1
  142. package/dist/web/webmcp-ui.js +16 -3
  143. package/package.json +17 -17
@@ -0,0 +1,113 @@
1
+ /**
2
+ * BrowserSpeechSTTAdapter errors: every Web Speech error reaches `onError`
3
+ * with its code (`speechError`), including an `aborted` the adapter did not
4
+ * cause, and through smrt-ui's `Dictation` Brave's "starts, then `network`"
5
+ * becomes a plain error instead of a silent stop.
6
+ */
7
+ import { Dictation } from '@happyvertical/smrt-ui/forms';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { BrowserSpeechSTTAdapter, } from './browser-speech.js';
10
+ import { createSttDictationSource } from './dictation-source.js';
11
+ class FakeRecognition {
12
+ static last = null;
13
+ /** What the browser does after start(): Brave errors with `network`. */
14
+ static afterStart = null;
15
+ lang = '';
16
+ continuous = false;
17
+ interimResults = false;
18
+ maxAlternatives = 1;
19
+ onresult = null;
20
+ onerror = null;
21
+ onstart = null;
22
+ onend = null;
23
+ constructor() {
24
+ FakeRecognition.last = this;
25
+ }
26
+ start = vi.fn(() => {
27
+ this.onstart?.();
28
+ FakeRecognition.afterStart?.(this);
29
+ });
30
+ stop = vi.fn(() => this.onend?.());
31
+ abort = vi.fn(() => {
32
+ this.onerror?.({ error: 'aborted', message: '' });
33
+ this.onend?.();
34
+ });
35
+ fail(code) {
36
+ this.onerror?.({ error: code, message: '' });
37
+ this.onend?.();
38
+ }
39
+ }
40
+ function install() {
41
+ window.webkitSpeechRecognition =
42
+ FakeRecognition;
43
+ }
44
+ afterEach(() => {
45
+ delete window
46
+ .webkitSpeechRecognition;
47
+ FakeRecognition.last = null;
48
+ FakeRecognition.afterStart = null;
49
+ });
50
+ describe('BrowserSpeechSTTAdapter errors', () => {
51
+ it.each([
52
+ 'network',
53
+ 'not-allowed',
54
+ 'service-not-allowed',
55
+ 'no-speech',
56
+ 'audio-capture',
57
+ 'aborted',
58
+ 'language-not-supported',
59
+ ])('reports %s with its code', async (code) => {
60
+ install();
61
+ const adapter = new BrowserSpeechSTTAdapter();
62
+ const errors = [];
63
+ adapter.onError((error) => errors.push(error));
64
+ await adapter.start({ continuous: true });
65
+ FakeRecognition.last?.fail(code);
66
+ expect(errors).toHaveLength(1);
67
+ expect(errors[0].speechError).toBe(code);
68
+ });
69
+ it('does not report the abort it caused itself', async () => {
70
+ install();
71
+ const adapter = new BrowserSpeechSTTAdapter();
72
+ const onError = vi.fn();
73
+ adapter.onError(onError);
74
+ await adapter.start();
75
+ adapter.abort();
76
+ expect(onError).not.toHaveBeenCalled();
77
+ });
78
+ });
79
+ describe('Dictation over the browser adapter', () => {
80
+ it("Brave: it beeps, then 'network' — shown as unavailable, not a silent stop", async () => {
81
+ install();
82
+ FakeRecognition.afterStart = (r) => queueMicrotask(() => r.fail('network'));
83
+ const log = vi.fn();
84
+ const beep = vi.fn(async () => true);
85
+ const dictation = new Dictation({
86
+ source: createSttDictationSource(),
87
+ onText: () => { },
88
+ beep,
89
+ log,
90
+ requestMicrophone: false,
91
+ });
92
+ await dictation.start();
93
+ await vi.waitFor(() => expect(dictation.state).toBe('error'));
94
+ expect(beep).toHaveBeenCalled();
95
+ expect(dictation.errorKind).toBe('unsupported');
96
+ expect(dictation.errorCode).toBe('network');
97
+ expect(log).toHaveBeenCalledWith(expect.objectContaining({ kind: 'unsupported', code: 'network' }));
98
+ });
99
+ it('an end straight after starting with no error is a failure too', async () => {
100
+ install();
101
+ FakeRecognition.afterStart = (r) => queueMicrotask(() => r.onend?.());
102
+ const dictation = new Dictation({
103
+ source: createSttDictationSource(),
104
+ onText: () => { },
105
+ beep: false,
106
+ log: () => { },
107
+ requestMicrophone: false,
108
+ });
109
+ await dictation.start();
110
+ await vi.waitFor(() => expect(dictation.state).toBe('error'));
111
+ expect(dictation.errorKind).toBe('interrupted');
112
+ });
113
+ });
@@ -0,0 +1,3 @@
1
+ import type { GetSTTOptions, STTAdapter } from './types.js';
2
+ export declare function createSttDictationSource(options?: GetSTTOptions): () => Promise<STTAdapter>;
3
+ //# sourceMappingURL=dictation-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dictation-source.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/stt/dictation-source.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5D,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,aAA0C,GAClD,MAAM,OAAO,CAAC,UAAU,CAAC,CAc3B"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A speech source for smrt-ui's `Dictation` (the microphone button and
3
+ * "press and hold a field to speak" in `@happyvertical/smrt-ui/forms`).
4
+ *
5
+ * `STTAdapter` already has the shape `Dictation` needs (`start`, `stop`,
6
+ * `onResult`, `onError`, `onStart`, `onEnd`), so this only creates the
7
+ * adapter lazily, once, the first time someone starts dictating: nothing
8
+ * loads and no microphone prompt appears until then. Defaults to the
9
+ * browser's own speech recognition (`browser-speech`), which needs no
10
+ * download; an unsupported browser rejects with
11
+ * `CapabilityNotAvailableError`, which `Dictation` reports in plain words.
12
+ *
13
+ * @example
14
+ * ```svelte
15
+ * <script>
16
+ * import { createSttDictationSource } from '@happyvertical/smrt-svelte/browser-ai';
17
+ * import { Dictation, DictationButton } from '@happyvertical/smrt-ui/forms';
18
+ * const dictation = new Dictation({ source: createSttDictationSource(), onText: (t) => … });
19
+ * </script>
20
+ * <DictationButton {dictation} />
21
+ * ```
22
+ */
23
+ import { getSTT } from './factory.js';
24
+ export function createSttDictationSource(options = { type: 'browser-speech' }) {
25
+ let adapter = null;
26
+ return () => {
27
+ adapter ??= (async () => {
28
+ const created = await getSTT(options);
29
+ await created.ensureInitialized();
30
+ return created;
31
+ })().catch((error) => {
32
+ // Let a later tap try again (for example after a permission change).
33
+ adapter = null;
34
+ throw error;
35
+ });
36
+ return adapter;
37
+ };
38
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * createSttDictationSource: the browser speech adapter, created once and
3
+ * lazily, and rejected plainly where the browser has no speech recognition.
4
+ */
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { createSttDictationSource } from './dictation-source.js';
7
+ class FakeRecognition {
8
+ lang = '';
9
+ continuous = false;
10
+ interimResults = false;
11
+ maxAlternatives = 1;
12
+ onresult = null;
13
+ onerror = null;
14
+ onstart = null;
15
+ onend = null;
16
+ start = vi.fn(() => this.onstart?.());
17
+ stop = vi.fn(() => this.onend?.());
18
+ abort = vi.fn();
19
+ }
20
+ describe('createSttDictationSource', () => {
21
+ afterEach(() => {
22
+ delete window.SpeechRecognition;
23
+ delete window
24
+ .webkitSpeechRecognition;
25
+ });
26
+ it('creates one browser speech adapter, on first use', async () => {
27
+ window.webkitSpeechRecognition =
28
+ FakeRecognition;
29
+ const provide = createSttDictationSource();
30
+ const first = await provide();
31
+ const second = await provide();
32
+ expect(first).toBe(second);
33
+ expect(first.type).toBe('browser-speech');
34
+ const onStart = vi.fn();
35
+ first.onStart(onStart);
36
+ await first.start({
37
+ language: 'en-CA',
38
+ continuous: true,
39
+ interimResults: true,
40
+ });
41
+ expect(onStart).toHaveBeenCalled();
42
+ });
43
+ it('rejects with CapabilityNotAvailableError without speech recognition, then can retry', async () => {
44
+ const provide = createSttDictationSource();
45
+ await expect(provide()).rejects.toMatchObject({
46
+ name: 'CapabilityNotAvailableError',
47
+ });
48
+ window.SpeechRecognition =
49
+ FakeRecognition;
50
+ await expect(provide()).resolves.toBeTruthy();
51
+ });
52
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Speech-to-Text adapters
3
3
  */
4
- export { BrowserSpeechSTTAdapter } from './browser-speech.js';
4
+ export { type BrowserSpeechError, BrowserSpeechSTTAdapter, } from './browser-speech.js';
5
+ export { createSttDictationSource } from './dictation-source.js';
5
6
  export * from './factory.js';
6
7
  export * from './types.js';
7
8
  export { WhisperCppSTTAdapter } from './whisper-cpp.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/stt/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/stt/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,KAAK,kBAAkB,EACvB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AACjE,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Speech-to-Text adapters
3
3
  */
4
- export { BrowserSpeechSTTAdapter } from './browser-speech.js';
4
+ export { BrowserSpeechSTTAdapter, } from './browser-speech.js';
5
+ export { createSttDictationSource } from './dictation-source.js';
5
6
  export * from './factory.js';
6
7
  export * from './types.js';
7
8
  export { WhisperCppSTTAdapter } from './whisper-cpp.js';
@@ -8,7 +8,19 @@
8
8
  * - Accept filter (mime types / extensions)
9
9
  * - Multiple file support
10
10
  * - Max size validation
11
+ * - Native form posting via `name` (the accepted list is mirrored onto the
12
+ * real input with `DataTransfer`, so picks, drops and removals post exactly
13
+ * what the list shows), `required` validation, and camera `capture`
14
+ * - A reset of the owning form (`form.reset()`, a reset button, SvelteKit
15
+ * `enhance`'s `update()` after a success) empties the list, as it empties a
16
+ * native file input, and reports it through `onchange([])`
11
17
  * - Material 3 styling
18
+ *
19
+ * Fallback: where the `DataTransfer` constructor is unavailable (Safari < 14.1,
20
+ * jsdom), the input keeps the browser's own selection only while it matches the
21
+ * accepted list exactly; otherwise it is cleared. A native submit then never
22
+ * posts a file the list does not show, but drops, accumulated picks and
23
+ * partial removals post nothing until the next matching pick.
12
24
  */
13
25
 
14
26
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
@@ -23,7 +35,7 @@ export interface Props {
23
35
  multiple?: boolean;
24
36
  /** Selected files (bindable) */
25
37
  files?: File[];
26
- /** Callback when files change */
38
+ /** Callback when files change, including the empty list after a form reset */
27
39
  onchange?: (files: File[]) => void;
28
40
  /** Maximum file size in bytes */
29
41
  maxSize?: number;
@@ -33,6 +45,12 @@ export interface Props {
33
45
  label?: string;
34
46
  /** Hint text shown below the label */
35
47
  hint?: string;
48
+ /** Form field name; the accepted files post under it in a native `multipart/form-data` submit */
49
+ name?: string;
50
+ /** Camera hint forwarded to the input: `environment` (rear) or `user` (front) */
51
+ capture?: 'environment' | 'user';
52
+ /** Require at least one file for native form validation */
53
+ required?: boolean;
36
54
  }
37
55
 
38
56
  let {
@@ -44,12 +62,61 @@ let {
44
62
  disabled = false,
45
63
  label = 'Drag and drop files here',
46
64
  hint = 'or click to browse',
65
+ name,
66
+ capture,
67
+ required = false,
47
68
  }: Props = $props();
48
69
 
49
70
  let isDragging = $state(false);
50
71
  let error = $state<string | null>(null);
51
72
  let inputRef = $state<HTMLInputElement | null>(null);
52
73
 
74
+ // Mirror the accepted list onto the real input so a native form submit posts
75
+ // exactly what the list shows (picks, drops, removals, and external resets of
76
+ // the bound `files`). Assigning `input.files` fires no change event.
77
+ $effect(() => {
78
+ const input = inputRef;
79
+ const current = files;
80
+ if (!input) return;
81
+ if (typeof DataTransfer === 'function') {
82
+ try {
83
+ const transfer = new DataTransfer();
84
+ for (const file of current) transfer.items.add(file);
85
+ input.files = transfer.files;
86
+ return;
87
+ } catch {
88
+ // Constructor present but unusable; fall through to the fallback.
89
+ }
90
+ }
91
+ const native = input.files ? Array.from(input.files) : [];
92
+ const matches =
93
+ native.length === current.length &&
94
+ native.every((file, i) => file === current[i]);
95
+ if (!matches) input.value = '';
96
+ });
97
+
98
+ // The owning form reset: the browser empties the native input, so the list
99
+ // must empty too or it keeps showing files that no longer post. `reset` fires
100
+ // before the browser resets the controls; clearing `files` makes the mirror
101
+ // effect above assign an empty list, so the two agree instead of fighting
102
+ // (and a cancelled reset still leaves list and input both empty). The capture
103
+ // phase on `document` reads the input's form at dispatch time, so moving the
104
+ // input between forms needs no re-wiring and no page listener can hide it.
105
+ $effect(() => {
106
+ if (typeof document === 'undefined') return;
107
+ const onReset = (event: Event) => {
108
+ const form = inputRef?.form;
109
+ if (!form || event.target !== form) return;
110
+ const hadFiles = files.length > 0;
111
+ error = null;
112
+ isDragging = false;
113
+ files = [];
114
+ if (hadFiles) onchange?.(files);
115
+ };
116
+ document.addEventListener('reset', onReset, true);
117
+ return () => document.removeEventListener('reset', onReset, true);
118
+ });
119
+
53
120
  function handleDragEnter(e: DragEvent) {
54
121
  e.preventDefault();
55
122
  if (!disabled) isDragging = true;
@@ -79,10 +146,16 @@ function handleFileSelect(e: Event) {
79
146
  const input = e.target as HTMLInputElement;
80
147
  if (input.files) {
81
148
  addFiles(Array.from(input.files));
82
- input.value = '';
83
149
  }
84
150
  }
85
151
 
152
+ function handleInvalid(e: Event) {
153
+ const input = e.currentTarget as HTMLInputElement;
154
+ // The input is visually hidden and aria-hidden, so announce the native
155
+ // validation failure through the live error region as well.
156
+ error = input.validationMessage || t(M['ui.file_upload.required']);
157
+ }
158
+
86
159
  function handleZoneClick() {
87
160
  if (!disabled) inputRef?.click();
88
161
  }
@@ -127,18 +200,24 @@ function addFiles(newFiles: File[]) {
127
200
  // Validate accepted types
128
201
  const rejected = filtered.filter((f) => !isFileAccepted(f));
129
202
  if (rejected.length > 0) {
130
- errorMessages.push(`${rejected.length} file(s) rejected: type not allowed`);
203
+ errorMessages.push(
204
+ t(M['ui.file_upload.rejected_type'], { count: rejected.length }),
205
+ );
131
206
  filtered = filtered.filter((f) => isFileAccepted(f));
132
207
  }
133
208
 
134
209
  // Validate max size
135
- if (maxSize) {
136
- const oversized = filtered.filter((f) => f.size > maxSize!);
210
+ const limit = maxSize;
211
+ if (limit) {
212
+ const oversized = filtered.filter((f) => f.size > limit);
137
213
  if (oversized.length > 0) {
138
214
  errorMessages.push(
139
- `${oversized.length} file(s) exceed the maximum size of ${formatFileSize(maxSize)}`,
215
+ t(M['ui.file_upload.exceeds_max_size'], {
216
+ count: oversized.length,
217
+ size: formatFileSize(limit),
218
+ }),
140
219
  );
141
- filtered = filtered.filter((f) => f.size <= maxSize!);
220
+ filtered = filtered.filter((f) => f.size <= limit);
142
221
  }
143
222
  }
144
223
 
@@ -192,16 +271,26 @@ function formatFileSize(bytes: number): string {
192
271
  </div>
193
272
  <p class="drop-zone__label">{label}</p>
194
273
  <p class="drop-zone__hint">{hint}</p>
195
- <input
196
- bind:this={inputRef}
197
- type="file"
198
- {accept}
199
- {multiple}
200
- onchange={handleFileSelect}
201
- hidden
202
- {disabled}
203
- />
204
274
  </div>
275
+ <!-- Visually hidden rather than `hidden`: a hidden control cannot be focused,
276
+ so the browser could not show its `required` validation bubble and would
277
+ silently block the submit. The drop zone stays the one control exposed
278
+ to assistive tech and the tab order. -->
279
+ <input
280
+ bind:this={inputRef}
281
+ class="file-upload__input"
282
+ type="file"
283
+ {name}
284
+ {accept}
285
+ {multiple}
286
+ {capture}
287
+ {required}
288
+ {disabled}
289
+ tabindex="-1"
290
+ aria-hidden="true"
291
+ onchange={handleFileSelect}
292
+ oninvalid={handleInvalid}
293
+ />
205
294
 
206
295
  {#if error}
207
296
  <!-- Live region so async validation/reject errors are announced (C7),
@@ -235,11 +324,26 @@ function formatFileSize(bytes: number): string {
235
324
 
236
325
  <style>
237
326
  .file-upload {
327
+ position: relative;
238
328
  display: flex;
239
329
  flex-direction: column;
240
330
  gap: var(--smrt-spacing-3, 0.75rem);
241
331
  }
242
332
 
333
+ .file-upload__input {
334
+ position: absolute;
335
+ inset-block-start: 0;
336
+ inset-inline-start: 0;
337
+ width: 1px;
338
+ height: 1px;
339
+ margin: 0;
340
+ padding: 0;
341
+ border: 0;
342
+ overflow: hidden;
343
+ clip-path: inset(50%);
344
+ white-space: nowrap;
345
+ }
346
+
243
347
  .file-upload.disabled {
244
348
  opacity: 0.5;
245
349
  pointer-events: none;
@@ -5,7 +5,7 @@ export interface Props {
5
5
  multiple?: boolean;
6
6
  /** Selected files (bindable) */
7
7
  files?: File[];
8
- /** Callback when files change */
8
+ /** Callback when files change, including the empty list after a form reset */
9
9
  onchange?: (files: File[]) => void;
10
10
  /** Maximum file size in bytes */
11
11
  maxSize?: number;
@@ -15,6 +15,12 @@ export interface Props {
15
15
  label?: string;
16
16
  /** Hint text shown below the label */
17
17
  hint?: string;
18
+ /** Form field name; the accepted files post under it in a native `multipart/form-data` submit */
19
+ name?: string;
20
+ /** Camera hint forwarded to the input: `environment` (rear) or `user` (front) */
21
+ capture?: 'environment' | 'user';
22
+ /** Require at least one file for native form validation */
23
+ required?: boolean;
18
24
  }
19
25
  declare const FileUpload: import("svelte").Component<Props, {}, "files">;
20
26
  type FileUpload = ReturnType<typeof FileUpload>;
@@ -1 +1 @@
1
- {"version":3,"file":"FileUpload.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FileUpload.svelte.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,iCAAiC;IACjC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACnC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA0LD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"FileUpload.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FileUpload.svelte.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACnC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iGAAiG;IACjG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAAC;IACjC,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwPD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}