@happyvertical/smrt-svelte 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.
- package/README.md +47 -0
- package/agents/components.md +8 -1
- package/dist/Provider.svelte +19 -15
- package/dist/Provider.svelte.d.ts +2 -2
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/__tests__/provider-mode-harness.svelte +14 -0
- package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
- package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
- package/dist/__tests__/provider-mode-probe.svelte +13 -0
- package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
- package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
- package/dist/components/forms/FileUpload.svelte +120 -16
- package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
- package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +12 -1
- package/dist/components/workspace/__tests__/AdminShell.test.js +172 -1
- package/dist/components/workspace/__tests__/TenantNav.test.js +66 -0
- package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
- package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte +108 -26
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/TenantNav.svelte +70 -12
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/state.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/state.svelte.js +7 -0
- package/dist/components/workspace/admin-shell/types.d.ts +18 -1
- package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
- package/dist/components/workspace/index.d.ts +1 -1
- package/dist/components/workspace/index.d.ts.map +1 -1
- package/dist/i18n/strings.forms.d.ts +3 -0
- package/dist/i18n/strings.forms.d.ts.map +1 -1
- package/dist/i18n/strings.forms.js +3 -0
- package/dist/i18n/strings.workspace.d.ts +4 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +4 -0
- package/dist/provider-mode.test.js +86 -0
- package/dist/state/app-state.svelte.d.ts.map +1 -1
- package/dist/state/app-state.svelte.js +8 -8
- 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`.
|
package/agents/components.md
CHANGED
|
@@ -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
|
package/dist/Provider.svelte
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
118
|
-
const appState =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
*
|
|
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
|
|
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;
|
|
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(
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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 <=
|
|
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":"
|
|
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"}
|