@happyvertical/smrt-svelte 0.52.0 → 0.53.1

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 (49) hide show
  1. package/README.md +47 -0
  2. package/agents/components.md +8 -1
  3. package/dist/Provider.svelte +19 -15
  4. package/dist/Provider.svelte.d.ts +2 -2
  5. package/dist/Provider.svelte.d.ts.map +1 -1
  6. package/dist/__tests__/provider-mode-harness.svelte +14 -0
  7. package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
  8. package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
  9. package/dist/__tests__/provider-mode-probe.svelte +13 -0
  10. package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
  11. package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
  12. package/dist/components/forms/FileUpload.svelte +120 -16
  13. package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
  14. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  15. package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
  16. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
  17. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
  18. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
  19. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +12 -1
  20. package/dist/components/workspace/__tests__/AdminShell.test.js +172 -1
  21. package/dist/components/workspace/__tests__/TenantNav.test.js +66 -0
  22. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
  23. package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
  24. package/dist/components/workspace/admin-shell/AdminShell.svelte +108 -26
  25. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +2 -0
  26. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  27. package/dist/components/workspace/admin-shell/TenantNav.svelte +70 -12
  28. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
  29. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  30. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
  31. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
  32. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
  33. package/dist/components/workspace/admin-shell/state.svelte.d.ts +2 -0
  34. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  35. package/dist/components/workspace/admin-shell/state.svelte.js +7 -0
  36. package/dist/components/workspace/admin-shell/types.d.ts +18 -1
  37. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  38. package/dist/components/workspace/index.d.ts +1 -1
  39. package/dist/components/workspace/index.d.ts.map +1 -1
  40. package/dist/i18n/strings.forms.d.ts +3 -0
  41. package/dist/i18n/strings.forms.d.ts.map +1 -1
  42. package/dist/i18n/strings.forms.js +3 -0
  43. package/dist/i18n/strings.workspace.d.ts +4 -0
  44. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  45. package/dist/i18n/strings.workspace.js +4 -0
  46. package/dist/provider-mode.test.js +86 -0
  47. package/dist/state/app-state.svelte.d.ts.map +1 -1
  48. package/dist/state/app-state.svelte.js +8 -8
  49. package/package.json +16 -16
package/README.md CHANGED
@@ -613,3 +613,50 @@ public registry interactions. It does not require `document.modelContext` or
613
613
  export mounted closures to remote MCP. Agent proposals retain trusted human
614
614
  staged review. See the [bridge contract](../mcp-apps/README.md) for origin
615
615
  configuration, capabilities, fallback and synthetic-browser evidence limits.
616
+
617
+ AdminShell's optional `homeHref` turns default branding into a named home link.
618
+ Use `logoSrc`/`logoAlt` for a logo or a `brand` snippet receiving `{ compact }`
619
+ for custom marks. A collapsed tenant rail keeps a compact linked logo or initial;
620
+ the default app bar keeps the full brand visible at narrow widths. A custom
621
+ `appBar` continues to own its branding. Without these props, branding stays text.
622
+
623
+ ### Voice mode migration (#3261)
624
+
625
+ **Changed default:** Provider and standalone `createAppState()` now keep voice
626
+ mode off unless explicitly requested. Omitted `autoEnableSmrt` defaults to
627
+ `false`; speech-capable browsers stay in `default` mode. Apps that relied on
628
+ automatic voice activation must opt in with `autoEnableSmrt={true}`. Use
629
+ `mode="smrt"` for an explicit voice-mode choice. An explicit `mode="default"`
630
+ wins over auto-detection from the start of initialization, including when
631
+ `autoEnableSmrt` is true; no transient voice-mode activation occurs.
632
+ Standalone state callers can opt in through initial session preferences
633
+ (`autoEnableSmrt: true`); `initialMode` takes precedence. Runtime mode prop
634
+ updates retain their existing behavior.
635
+
636
+ ### Runtime panel controls (#3246)
637
+
638
+ Use `shell.setPanelState(edge, 'hidden' | 'collapsed' | 'expanded')` to persist
639
+ runtime panel preferences; app-configured hidden edges remain unavailable.
640
+ AdminShell keeps a discoverable tenant collapse control with supplied navigation
641
+ (`showTenantToggle={false}` opts out), a Menu opener in narrow layouts, and the
642
+ system toggle alongside a custom `systemBar`. Closed narrow drawers are inert;
643
+ opening focuses the first control, and closing or Escape restores the opener.
644
+ TenantNav accepts `density="touch"` for canonical 48px link targets, including
645
+ collapsed rail links; omit density to inherit the theme's control density.
646
+
647
+ `AdminShell` keeps a supplied `tenantFooter` in the app bar while the left pane
648
+ is collapsed, hidden, or a closed narrow drawer; it moves back to the expanded
649
+ pane without duplicate account controls. Use the optional `account` snippet for
650
+ an account permanently in the app bar (including with a custom `appBar`).
651
+ `WorkspaceAccountMenu density="touch"` sizes its trigger and menu actions to the
652
+ canonical 48px target; omit density to inherit the theme, or choose
653
+ `"comfortable"`. Account menus in the shell app bar open below the bar so the
654
+ existing footer's default placement stays reachable on narrow screens.
655
+
656
+ `TenantNav` accepts optional `groups: ShellNavGroup[]` (`{ heading, items }`)
657
+ next to its existing flat `items`. Each group has a labelled `role="group"` and
658
+ native disclosure summary; keyboard and touch users can collapse it even in the
659
+ icon rail. Groups start open, preserve nested links and active-route markers,
660
+ and inherit the navigation density. Supply `aria-label="Shop navigation"` for
661
+ an instance-specific landmark name; omission uses the localized default.
662
+ `ShellNavGroup` is exported from `@happyvertical/smrt-svelte/workspace`.
@@ -34,7 +34,14 @@ props, a golden test, and a playground page (`playground/.../primitives`).
34
34
  migrate domain re-rolls *onto* them rather than build new primitives:
35
35
 
36
36
  - **`FileUpload`** (`./forms`) — the canonical upload input; replace ad-hoc
37
- drop zones.
37
+ drop zones. With `name` it posts in a native `multipart/form-data` submit:
38
+ the accepted list (picks, drops, removals, bound resets) is mirrored onto the
39
+ visually-hidden input via `DataTransfer` — never `hidden`, which would break
40
+ `required` validation. Without that constructor the input keeps only a
41
+ native selection that matches the list, else clears (#3260). A reset of the
42
+ owning form (`form.reset()`, a reset button, `enhance`'s `update()`) empties
43
+ the list and reports `onchange([])`, so the list never shows files that no
44
+ longer post.
38
45
  - **`Modal` + forms** (`./feedback` + `./forms`) — compose for dialogs; no
39
46
  bespoke modal shells.
40
47
  - **`ConfirmDialog`** (`./feedback`) — the standard confirm/destructive-action
@@ -26,12 +26,12 @@ import { setWebMcpUiContext } from './web/webmcp-ui-context.js';
26
26
  interface Props {
27
27
  /**
28
28
  * Initial mode: 'default' | 'smrt'
29
- * If not provided, mode is auto-detected based on capabilities
29
+ * Defaults to standard mode; auto-detection requires autoEnableSmrt=true
30
30
  */
31
31
  mode?: AppMode;
32
32
  /**
33
33
  * Whether to auto-enable smrt mode when capabilities are available
34
- * @default true
34
+ * @default false
35
35
  */
36
36
  autoEnableSmrt?: boolean;
37
37
  /**
@@ -98,7 +98,7 @@ interface Props {
98
98
 
99
99
  const {
100
100
  mode,
101
- autoEnableSmrt = true,
101
+ autoEnableSmrt = false,
102
102
  user = null,
103
103
  permissions = [],
104
104
  socket,
@@ -114,18 +114,22 @@ const {
114
114
  // Determine if we should show the loading overlay
115
115
  const showLoadingOverlay = $derived(ai?.showLoadingOverlay ?? true);
116
116
 
117
- // Create app state
118
- const appState = createAppState({
119
- onCapabilitiesDetected: () => {
120
- onReady?.();
121
- },
122
- onModeChange: (newMode) => {
123
- onModeChange?.(newMode);
124
- },
125
- onAILoadingChange: (state) => {
126
- onAILoadingChange?.(state);
127
- },
128
- });
117
+ // Capture initial props once; the effects below own subsequent prop updates.
118
+ const appState = untrack(() =>
119
+ createAppState({
120
+ initialMode: mode,
121
+ session: { preferences: { autoEnableSmrt } },
122
+ onCapabilitiesDetected: () => {
123
+ onReady?.();
124
+ },
125
+ onModeChange: (newMode) => {
126
+ onModeChange?.(newMode);
127
+ },
128
+ onAILoadingChange: (state) => {
129
+ onAILoadingChange?.(state);
130
+ },
131
+ }),
132
+ );
129
133
 
130
134
  const localControlRegistry = createControlInteractionRegistry();
131
135
  const localDataSurfaceRegistry = createDataSurfaceRegistry();
@@ -5,12 +5,12 @@ import type { WebMcpProviderConfig } from './web/webmcp-provider.js';
5
5
  interface Props {
6
6
  /**
7
7
  * Initial mode: 'default' | 'smrt'
8
- * If not provided, mode is auto-detected based on capabilities
8
+ * Defaults to standard mode; auto-detection requires autoEnableSmrt=true
9
9
  */
10
10
  mode?: AppMode;
11
11
  /**
12
12
  * Whether to auto-enable smrt mode when capabilities are available
13
- * @default true
13
+ * @default false
14
14
  */
15
15
  autoEnableSmrt?: boolean;
16
16
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAI9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAKrE,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,oBAAoB,CAAC;IACxC;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AA4PD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAI9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAKrE,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,oBAAoB,CAAC;IACxC;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAgQD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,14 @@
1
+ <script lang="ts">
2
+ import Provider from '../Provider.svelte';
3
+ import type { AppMode } from '../state/app-state.js';
4
+ import type { SmrtAppStateManager } from '../state/app-state.svelte.js';
5
+ import Probe from './provider-mode-probe.svelte';
6
+
7
+ interface Props {
8
+ mode?: AppMode;
9
+ autoEnableSmrt?: boolean;
10
+ capture: (state: SmrtAppStateManager) => void;
11
+ }
12
+ const { mode, autoEnableSmrt, capture }: Props = $props();
13
+ </script>
14
+ <Provider {mode} {autoEnableSmrt}><Probe {capture} /></Provider>
@@ -0,0 +1,11 @@
1
+ import type { AppMode } from '../state/app-state.js';
2
+ import type { SmrtAppStateManager } from '../state/app-state.svelte.js';
3
+ interface Props {
4
+ mode?: AppMode;
5
+ autoEnableSmrt?: boolean;
6
+ capture: (state: SmrtAppStateManager) => void;
7
+ }
8
+ declare const ProviderModeHarness: import("svelte").Component<Props, {}, "">;
9
+ type ProviderModeHarness = ReturnType<typeof ProviderModeHarness>;
10
+ export default ProviderModeHarness;
11
+ //# sourceMappingURL=provider-mode-harness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-mode-harness.svelte.d.ts","sourceRoot":"","sources":["../../src/__tests__/provider-mode-harness.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAIxE,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC/C;AAaD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import type { SmrtAppStateManager } from '../state/app-state.svelte.js';
4
+ import { getAppStateContext } from '../state/context.js';
5
+
6
+ interface Props {
7
+ capture: (state: SmrtAppStateManager) => void;
8
+ }
9
+ const { capture }: Props = $props();
10
+ const appState = getAppStateContext();
11
+ untrack(() => capture(appState));
12
+ </script>
13
+ <output aria-label="App mode">{appState.state.mode}</output>
@@ -0,0 +1,8 @@
1
+ import type { SmrtAppStateManager } from '../state/app-state.svelte.js';
2
+ interface Props {
3
+ capture: (state: SmrtAppStateManager) => void;
4
+ }
5
+ declare const ProviderModeProbe: import("svelte").Component<Props, {}, "">;
6
+ type ProviderModeProbe = ReturnType<typeof ProviderModeProbe>;
7
+ export default ProviderModeProbe;
8
+ //# sourceMappingURL=provider-mode-probe.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-mode-probe.svelte.d.ts","sourceRoot":"","sources":["../../src/__tests__/provider-mode-probe.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAIxE,UAAU,KAAK;IACb,OAAO,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC/C;AAcD,QAAA,MAAM,iBAAiB,2CAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
@@ -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"}