@void/svelte 1.5.0 → 1.6.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/dist/plugin.d.mts +2 -6
- package/dist/plugin.mjs +36 -90
- package/package.json +3 -3
- package/src/runtime/App.svelte +1 -1
- package/src/runtime/Link.svelte +41 -113
- package/src/runtime/link-props.ts +16 -0
- package/src/runtime/use-form.svelte.ts +33 -47
- package/src/runtime/use-island-form.svelte.ts +11 -21
package/dist/plugin.d.mts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
2
|
+
import { PrefetchConfig } from "void/pages";
|
|
2
3
|
import { Plugin } from "vite";
|
|
3
|
-
type PrefetchConfig = {
|
|
4
|
-
hoverDelay?: number;
|
|
5
|
-
cacheFor?: number | string | [string, string];
|
|
6
|
-
};
|
|
7
4
|
export type VoidSvelteOptions = Readonly<{
|
|
8
5
|
svelte?: Parameters<typeof svelte>[0];
|
|
9
6
|
viewTransitions?: boolean;
|
|
10
7
|
prefetch?: PrefetchConfig;
|
|
11
8
|
}>;
|
|
12
|
-
export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
|
|
13
|
-
export {};
|
|
9
|
+
export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
2
|
-
import { createPagesPlugin, generateComponentManifest,
|
|
2
|
+
import { createPagesPlugin, generateComponentManifest, resolvePagesDir, resolvePrefetchConfig, scanPages } from "void/pages";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
5
|
import { preprocess } from "svelte/compiler";
|
|
6
|
-
import {
|
|
7
|
-
import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, editIslandSource, findIslandImports, findStaticCssImports, hasIslandPages, islandStylesPlugin, recordIslandStyles, recordedIslandStyles, resolveSpecifier, rewriteCssImportForVirtualModule, watchPagesForRescan } from "void/pages-islands-plugin";
|
|
6
|
+
import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, createIslandManifest, editIslandSource, findIslandImports, generateIslandClientEntry, islandCssFiles, islandStylesPlugin, islandsBuildConfig, mdClientScriptIds, recordIslandStyles, recordedIslandStyles, watchPagesForRescan } from "void/pages-islands-plugin";
|
|
8
7
|
// ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
|
|
9
8
|
const BACKSLASH_RE = /\\/g;
|
|
10
9
|
const isWindows = process.platform === "win32";
|
|
@@ -49,6 +48,18 @@ const COMPONENT_EXTENSIONS = [
|
|
|
49
48
|
".ts",
|
|
50
49
|
".js"
|
|
51
50
|
];
|
|
51
|
+
const HYDRATE_ISLAND = `
|
|
52
|
+
async function hydrateIsland(el, mod, props, frontmatter) {
|
|
53
|
+
const { hydrate } = await import("svelte");
|
|
54
|
+
const Component = mod.default || mod;
|
|
55
|
+
const context = new Map([["__void_frontmatter", frontmatter]]);
|
|
56
|
+
hydrate(Component, { target: el, props, context });
|
|
57
|
+
el.setAttribute("data-hydrated", "true");
|
|
58
|
+
}`;
|
|
59
|
+
/** A Svelte component has at most one top-level `<style>`. */
|
|
60
|
+
function styleBlockImports(file, content) {
|
|
61
|
+
return /<style\b/.test(content) ? [`import ${JSON.stringify(file + ".css?island-style")};`] : [];
|
|
62
|
+
}
|
|
52
63
|
/**
|
|
53
64
|
* Prefix for virtual island wrapper modules. Must NOT use the `\0` prefix
|
|
54
65
|
* because `@sveltejs/vite-plugin-svelte` skips `\0`-prefixed modules in
|
|
@@ -58,27 +69,10 @@ const WRAPPER_PREFIX = "/@void-island-wrap/";
|
|
|
58
69
|
function islandsPlugin(pagesDir, options) {
|
|
59
70
|
let pageScan = null;
|
|
60
71
|
const root = options?.root ?? dirname(pagesDir);
|
|
61
|
-
const islandManifest =
|
|
62
|
-
|
|
72
|
+
const islandManifest = createIslandManifest(root, COMPONENT_EXTENSIONS);
|
|
73
|
+
let cssFiles = /* @__PURE__ */ new Set();
|
|
63
74
|
let isDev = false;
|
|
64
75
|
let resolvedConfig = null;
|
|
65
|
-
function registerIsland(spec, importerDir, hydrate) {
|
|
66
|
-
const absPath = resolveSpecifier(spec, importerDir, COMPONENT_EXTENSIONS);
|
|
67
|
-
if (!absPath) return {
|
|
68
|
-
absPath,
|
|
69
|
-
islandId: spec
|
|
70
|
-
};
|
|
71
|
-
const islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, "");
|
|
72
|
-
islandManifest.set(islandId, {
|
|
73
|
-
componentPath: absPath,
|
|
74
|
-
islandId,
|
|
75
|
-
hydrate
|
|
76
|
-
});
|
|
77
|
-
return {
|
|
78
|
-
absPath,
|
|
79
|
-
islandId
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
76
|
return [
|
|
83
77
|
{
|
|
84
78
|
name: "void-svelte:islands",
|
|
@@ -98,33 +92,8 @@ function islandsPlugin(pagesDir, options) {
|
|
|
98
92
|
pagesDir,
|
|
99
93
|
environment: isDev ? "dev" : "prod"
|
|
100
94
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
filePath: page.componentPath,
|
|
104
|
-
island: page.island
|
|
105
|
-
})),
|
|
106
|
-
...pageScan.layouts.map((layout) => ({
|
|
107
|
-
filePath: layout.filePath,
|
|
108
|
-
island: layout.island
|
|
109
|
-
})),
|
|
110
|
-
...pageScan.namedLayouts.map((namedLayout) => ({
|
|
111
|
-
filePath: namedLayout.filePath,
|
|
112
|
-
island: namedLayout.island
|
|
113
|
-
}))
|
|
114
|
-
];
|
|
115
|
-
for (const entry of filesToScan) {
|
|
116
|
-
if (!entry.island) continue;
|
|
117
|
-
const filePath = resolve(pagesDir, entry.filePath);
|
|
118
|
-
const code = readFileSync(filePath, "utf-8");
|
|
119
|
-
const importerDir = dirname(filePath);
|
|
120
|
-
for (const islandImport of findIslandImports(code, filePath)) registerIsland(islandImport.specifier, importerDir, islandImport.hydrate);
|
|
121
|
-
}
|
|
122
|
-
for (const page of pageScan.pages) {
|
|
123
|
-
if (!page.island) continue;
|
|
124
|
-
if (page.componentPath.endsWith(".svelte")) islandCssFiles.add(normalizePath(resolve(pagesDir, page.componentPath)));
|
|
125
|
-
const layoutChain = resolveEffectiveLayoutChain(page, pageScan.layouts, pageScan.namedLayouts);
|
|
126
|
-
for (const layout of layoutChain) islandCssFiles.add(normalizePath(resolve(pagesDir, layout.filePath)));
|
|
127
|
-
}
|
|
95
|
+
islandManifest.registerPages(pageScan, pagesDir);
|
|
96
|
+
cssFiles = islandCssFiles(pageScan, pagesDir, /\.svelte$/);
|
|
128
97
|
}
|
|
129
98
|
},
|
|
130
99
|
buildEnd() {
|
|
@@ -142,14 +111,21 @@ function islandsPlugin(pagesDir, options) {
|
|
|
142
111
|
handler(id) {
|
|
143
112
|
if (id === "\0" + ISLANDS_RENDERER_ID) {
|
|
144
113
|
if (!pageScan) return "export function renderIslandPage() { throw new Error('No pages found'); }";
|
|
145
|
-
const
|
|
146
|
-
const mdScriptIds = new Set(mdPlugin?.api?.getClientScripts?.()?.keys() ?? []);
|
|
114
|
+
const mdScriptIds = mdClientScriptIds(resolvedConfig);
|
|
147
115
|
const needsJS = computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig);
|
|
148
116
|
return generateIslandRenderer(pageScan, pagesDir, mdScriptIds, needsJS);
|
|
149
117
|
}
|
|
150
118
|
if (id === "\0" + ISLANDS_CLIENT_ENTRY_ID) {
|
|
151
119
|
if (!pageScan) return "";
|
|
152
|
-
return generateIslandClientEntry(
|
|
120
|
+
return generateIslandClientEntry({
|
|
121
|
+
manifest: islandManifest,
|
|
122
|
+
cssFiles,
|
|
123
|
+
styles: recordedIslandStyles(pagesDir),
|
|
124
|
+
needsRuntime: islandManifest.entries.size > 0 || computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
|
|
125
|
+
isDev,
|
|
126
|
+
styleBlockImports,
|
|
127
|
+
hydrateIsland: HYDRATE_ISLAND
|
|
128
|
+
});
|
|
153
129
|
}
|
|
154
130
|
if (id.startsWith(WRAPPER_PREFIX)) return generateWrapperComponent(id);
|
|
155
131
|
}
|
|
@@ -164,7 +140,7 @@ function islandsPlugin(pagesDir, options) {
|
|
|
164
140
|
const name = islandImport.importName;
|
|
165
141
|
const spec = islandImport.specifier;
|
|
166
142
|
const strategy = islandImport.hydrate;
|
|
167
|
-
const { absPath, islandId } =
|
|
143
|
+
const { absPath, islandId } = islandManifest.register(spec, importerDir, strategy);
|
|
168
144
|
const wrapperUrl = WRAPPER_PREFIX + encodeURIComponent(islandId) + ".svelte?src=" + encodeURIComponent(absPath || spec) + "&island=" + encodeURIComponent(islandId) + "&hydrate=" + encodeURIComponent(strategy);
|
|
169
145
|
const replacement = `import ${name} from ${JSON.stringify(wrapperUrl)}`;
|
|
170
146
|
edits.push({
|
|
@@ -179,10 +155,7 @@ function islandsPlugin(pagesDir, options) {
|
|
|
179
155
|
async config(_, env) {
|
|
180
156
|
if (!existsSync(pagesDir)) return;
|
|
181
157
|
if (env.command === "serve") return { optimizeDeps: { include: ["svelte"] } };
|
|
182
|
-
|
|
183
|
-
manifest: true,
|
|
184
|
-
rollupOptions: { input: { "islands-client": ISLANDS_CLIENT_ENTRY_ID } }
|
|
185
|
-
} } } };
|
|
158
|
+
return islandsBuildConfig(root, pagesDir);
|
|
186
159
|
}
|
|
187
160
|
},
|
|
188
161
|
{
|
|
@@ -297,35 +270,6 @@ export async function renderIslandPage(pageObj, assetTags) {
|
|
|
297
270
|
}
|
|
298
271
|
`;
|
|
299
272
|
}
|
|
300
|
-
function generateIslandClientEntry(manifest, cssFiles, styles, needsRuntime, isDev) {
|
|
301
|
-
const entries = [...manifest.values()];
|
|
302
|
-
const uniqueIslands = /* @__PURE__ */ new Map();
|
|
303
|
-
for (const entry of entries) uniqueIslands.set(entry.islandId, entry.componentPath);
|
|
304
|
-
const lines = [];
|
|
305
|
-
if (!isDev && styles) for (const id of styles) lines.push(`import ${JSON.stringify(id)};`);
|
|
306
|
-
else if (!isDev) for (const file of cssFiles) {
|
|
307
|
-
const content = readFileSync(file, "utf-8");
|
|
308
|
-
if (/<style\b/.test(content)) lines.push(`import ${JSON.stringify(file + ".css?island-style")};`);
|
|
309
|
-
for (const item of findStaticCssImports(content, file)) lines.push(rewriteCssImportForVirtualModule(item.statement, item.specifier, file));
|
|
310
|
-
}
|
|
311
|
-
if (!needsRuntime && !isDev) return lines.join("\n");
|
|
312
|
-
lines.push("import { initIslands } from \"void/pages-client\";");
|
|
313
|
-
lines.push("const islands = {");
|
|
314
|
-
for (const [id, path] of uniqueIslands) lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
|
|
315
|
-
lines.push("};");
|
|
316
|
-
lines.push(`
|
|
317
|
-
async function hydrateIsland(el, mod, props, frontmatter) {
|
|
318
|
-
const { hydrate } = await import("svelte");
|
|
319
|
-
const Component = mod.default || mod;
|
|
320
|
-
const context = new Map([["__void_frontmatter", frontmatter]]);
|
|
321
|
-
hydrate(Component, { target: el, props, context });
|
|
322
|
-
el.setAttribute("data-hydrated", "true");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
initIslands(islands, hydrateIsland);
|
|
326
|
-
`);
|
|
327
|
-
return lines.join("\n");
|
|
328
|
-
}
|
|
329
273
|
// src/plugin-pages.ts
|
|
330
274
|
const CLIENT_ENTRY_ID = "virtual:void-pages-client";
|
|
331
275
|
const SVELTE_OPTIMIZE_DEPS = {
|
|
@@ -385,7 +329,7 @@ import { serializePageData } from "void/pages-server";
|
|
|
385
329
|
|
|
386
330
|
${generateComponentManifest(scan, pagesDir)}
|
|
387
331
|
|
|
388
|
-
export async function renderPage(pageObj, assetTags) {
|
|
332
|
+
export async function renderPage(pageObj, assetTags, requestUrl) {
|
|
389
333
|
const PageComponent = (await components[pageObj.component]()).default;
|
|
390
334
|
const layoutIds = layoutTree[pageObj.component] || [];
|
|
391
335
|
const layoutComponents = await Promise.all(
|
|
@@ -399,7 +343,7 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
399
343
|
pageProps: pageObj.props,
|
|
400
344
|
shared: pageObj.shared || {},
|
|
401
345
|
params: pageObj.params || {},
|
|
402
|
-
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
|
|
346
|
+
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
|
|
403
347
|
routeUrl: pageObj.url || "/",
|
|
404
348
|
navigation: idleNavigationState(),
|
|
405
349
|
stores: null,
|
|
@@ -424,6 +368,7 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
424
368
|
`;
|
|
425
369
|
}
|
|
426
370
|
function generateClientEntry(scan, pagesDir, viewTransitions, prefetch, staticPageData) {
|
|
371
|
+
const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
|
|
427
372
|
return `
|
|
428
373
|
import { hydrate, mount, tick } from "svelte";
|
|
429
374
|
import { writable } from "svelte/store";
|
|
@@ -467,8 +412,9 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
|
|
|
467
412
|
staticPageData: ${!!staticPageData?.enabled},
|
|
468
413
|
staticPageDataFastPath: ${!!staticPageData?.fastPath},
|
|
469
414
|
viewTransitions: ${!!viewTransitions},
|
|
470
|
-
prefetchHoverDelay: ${
|
|
471
|
-
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(
|
|
415
|
+
prefetchHoverDelay: ${hoverDelay},
|
|
416
|
+
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
|
|
417
|
+
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
|
|
472
418
|
onRouteChange(url) {
|
|
473
419
|
routeUrl = url;
|
|
474
420
|
routeStore.set(url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@void/svelte",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Svelte pages, server rendering, forms, and navigation for Void.",
|
|
5
5
|
"homepage": "https://void.cloud",
|
|
6
6
|
"bugs": {
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"svelte": "^5.57.2",
|
|
44
44
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
|
|
45
45
|
"vite-plus": "1.4.0",
|
|
46
|
-
"void": "1.
|
|
46
|
+
"void": "1.6.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"svelte": "^5.0.0",
|
|
50
50
|
"vite": "^8.0.0",
|
|
51
|
-
"void": "1.
|
|
51
|
+
"void": "1.6.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "vp pack",
|
package/src/runtime/App.svelte
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
let params = $state(init.params || {});
|
|
29
29
|
let routeUrl = $state(init.routeUrl || "/");
|
|
30
30
|
let navigation = $state(init.navigation || idleNavigationState());
|
|
31
|
-
const router = createRouterFacade(init.router, () => routeUrl, () => params);
|
|
31
|
+
const router = init.router ? createRouterFacade(init.router, () => routeUrl, () => params) : null;
|
|
32
32
|
|
|
33
33
|
// Pages and layouts keep the objects returned by useShared() and useParams()
|
|
34
34
|
// across navigations, so update them in place.
|
package/src/runtime/Link.svelte
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getContext, onDestroy, onMount
|
|
2
|
+
import { getContext, onDestroy, onMount } from "svelte";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
handleLinkClick,
|
|
5
|
+
linkActiveAttributes,
|
|
6
|
+
linkPendingAttributes,
|
|
7
|
+
linkPrefetchStrategies,
|
|
8
|
+
observeLinkVisible,
|
|
9
|
+
prefetchLink,
|
|
10
|
+
resolveLink,
|
|
11
|
+
type NavigationState,
|
|
7
12
|
type VoidRouter,
|
|
8
13
|
} from "void/pages-client";
|
|
9
|
-
|
|
10
|
-
type PrefetchStrategy = "hover" | "click" | "mount" | "visible";
|
|
11
|
-
type LinkMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
12
|
-
type NavigateEvent = { preventDefault(): void };
|
|
13
|
-
type EventHandler<T extends Event> = (event: T) => void;
|
|
14
|
+
import type { LinkProps } from "./link-props.ts";
|
|
14
15
|
|
|
15
16
|
let {
|
|
16
17
|
href,
|
|
@@ -21,8 +22,9 @@
|
|
|
21
22
|
replace = false,
|
|
22
23
|
reloadDocument = false,
|
|
23
24
|
viewTransition = undefined,
|
|
24
|
-
prefetch: prefetchProp =
|
|
25
|
+
prefetch: prefetchProp = undefined,
|
|
25
26
|
cacheFor = undefined,
|
|
27
|
+
activeMatch = undefined,
|
|
26
28
|
onNavigate = undefined,
|
|
27
29
|
children,
|
|
28
30
|
onclick: onclickProp = undefined,
|
|
@@ -33,104 +35,30 @@
|
|
|
33
35
|
ontouchstart: onTouchStartProp = undefined,
|
|
34
36
|
style = undefined,
|
|
35
37
|
...rest
|
|
36
|
-
}:
|
|
37
|
-
href: string;
|
|
38
|
-
method?: LinkMethod | Lowercase<LinkMethod>;
|
|
39
|
-
data?: Record<string, unknown>;
|
|
40
|
-
preserveScroll?: boolean;
|
|
41
|
-
preserveState?: boolean;
|
|
42
|
-
replace?: boolean;
|
|
43
|
-
reloadDocument?: boolean;
|
|
44
|
-
viewTransition?: boolean;
|
|
45
|
-
prefetch?: boolean | PrefetchStrategy | Array<PrefetchStrategy>;
|
|
46
|
-
cacheFor?: number | string | [string, string];
|
|
47
|
-
onNavigate?: (event: NavigateEvent) => void;
|
|
48
|
-
children?: Snippet;
|
|
49
|
-
onclick?: EventHandler<MouseEvent>;
|
|
50
|
-
onmouseenter?: EventHandler<MouseEvent>;
|
|
51
|
-
onmouseleave?: EventHandler<MouseEvent>;
|
|
52
|
-
onfocus?: EventHandler<FocusEvent>;
|
|
53
|
-
onmousedown?: EventHandler<MouseEvent>;
|
|
54
|
-
ontouchstart?: EventHandler<TouchEvent>;
|
|
55
|
-
style?: string;
|
|
56
|
-
[key: string]: unknown;
|
|
57
|
-
} = $props();
|
|
38
|
+
}: LinkProps = $props();
|
|
58
39
|
|
|
59
40
|
const router = getContext<VoidRouter>("__void_router");
|
|
60
|
-
|
|
61
|
-
function getStrategies(): Array<PrefetchStrategy> {
|
|
62
|
-
if (prefetchProp !== false && !isGet) {
|
|
63
|
-
throw new Error(
|
|
64
|
-
'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".',
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
if (reloadDocument && !isGet) {
|
|
68
|
-
throw new Error("Link: reloadDocument only supports GET links.");
|
|
69
|
-
}
|
|
70
|
-
if (prefetchProp === false || reloadDocument) {
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
if (prefetchProp === true) {
|
|
74
|
-
return ["hover"];
|
|
75
|
-
}
|
|
76
|
-
if (typeof prefetchProp === "string") {
|
|
77
|
-
return [prefetchProp];
|
|
78
|
-
}
|
|
79
|
-
return prefetchProp;
|
|
80
|
-
}
|
|
41
|
+
const navigation = getContext<{ value: NavigationState } | undefined>("__void_navigation");
|
|
81
42
|
|
|
82
43
|
function doPrefetch() {
|
|
83
|
-
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const current = window.location.pathname + window.location.search;
|
|
87
|
-
if (hrefWithData === current) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const options: PrefetchOptions = { method: normalizedMethod };
|
|
91
|
-
if (cacheFor !== undefined) {
|
|
92
|
-
options.cacheFor = cacheFor;
|
|
93
|
-
}
|
|
94
|
-
router.prefetch(hrefWithData, options);
|
|
44
|
+
prefetchLink(router, link.href, cacheFor);
|
|
95
45
|
}
|
|
96
46
|
|
|
97
47
|
function onClick(e: MouseEvent) {
|
|
98
48
|
onclickProp?.(e);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
if ((e.currentTarget as HTMLElement).hasAttribute("download")) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const url = new URL(hrefWithData, window.location.origin);
|
|
106
|
-
if (url.origin !== window.location.origin) {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let navigatePrevented = false;
|
|
111
|
-
onNavigate?.({
|
|
112
|
-
preventDefault() {
|
|
113
|
-
navigatePrevented = true;
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
if (navigatePrevented) {
|
|
117
|
-
e.preventDefault();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
router.visit(hrefWithData, {
|
|
123
|
-
method: normalizedMethod,
|
|
124
|
-
data: isGet ? undefined : data,
|
|
49
|
+
handleLinkClick(e, router, link, {
|
|
50
|
+
data,
|
|
125
51
|
preserveScroll,
|
|
126
52
|
preserveState,
|
|
127
53
|
replace,
|
|
54
|
+
reloadDocument,
|
|
128
55
|
viewTransition,
|
|
56
|
+
onNavigate,
|
|
129
57
|
});
|
|
130
58
|
}
|
|
131
59
|
|
|
132
60
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
-
let
|
|
61
|
+
let stopObserving: (() => void) | null = null;
|
|
134
62
|
let el = $state<HTMLElement | null>(null);
|
|
135
63
|
|
|
136
64
|
function onMouseEnter(e: MouseEvent) {
|
|
@@ -178,17 +106,7 @@
|
|
|
178
106
|
doPrefetch();
|
|
179
107
|
}
|
|
180
108
|
if (strategies.includes("visible") && el) {
|
|
181
|
-
|
|
182
|
-
(entries) => {
|
|
183
|
-
if (entries.some((e) => e.isIntersecting)) {
|
|
184
|
-
doPrefetch();
|
|
185
|
-
observer?.disconnect();
|
|
186
|
-
observer = null;
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
{ rootMargin: "0px" },
|
|
190
|
-
);
|
|
191
|
-
observer.observe(el);
|
|
109
|
+
stopObserving = observeLinkVisible(el, doPrefetch);
|
|
192
110
|
}
|
|
193
111
|
});
|
|
194
112
|
|
|
@@ -196,25 +114,34 @@
|
|
|
196
114
|
if (hoverTimer) {
|
|
197
115
|
clearTimeout(hoverTimer);
|
|
198
116
|
}
|
|
199
|
-
|
|
200
|
-
observer.disconnect();
|
|
201
|
-
observer = null;
|
|
202
|
-
}
|
|
117
|
+
stopObserving?.();
|
|
203
118
|
});
|
|
204
119
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const strategies = $derived(
|
|
120
|
+
const link = $derived(
|
|
121
|
+
resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }),
|
|
122
|
+
);
|
|
123
|
+
const strategies = $derived(
|
|
124
|
+
linkPrefetchStrategies(
|
|
125
|
+
{ prefetch: prefetchProp, reloadDocument },
|
|
126
|
+
link,
|
|
127
|
+
router?._prefetchDefaultStrategy,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
// Reading router.url tracks the route URL, so this follows navigations.
|
|
131
|
+
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
|
|
132
|
+
// Reading navigation.value tracks visits as they start and end.
|
|
133
|
+
const pendingAttributes = $derived(linkPendingAttributes(router, navigation?.value, link));
|
|
209
134
|
const resolvedStyle = $derived(
|
|
210
135
|
style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
|
|
211
136
|
);
|
|
212
137
|
</script>
|
|
213
138
|
|
|
214
|
-
{#if isGet}
|
|
139
|
+
{#if link.isGet}
|
|
215
140
|
<a
|
|
216
141
|
bind:this={el}
|
|
217
|
-
href={
|
|
142
|
+
href={link.href}
|
|
143
|
+
{...activeAttributes}
|
|
144
|
+
{...pendingAttributes}
|
|
218
145
|
{...rest}
|
|
219
146
|
style={resolvedStyle}
|
|
220
147
|
onclick={onClick}
|
|
@@ -232,6 +159,7 @@
|
|
|
232
159
|
<button
|
|
233
160
|
bind:this={el}
|
|
234
161
|
type="button"
|
|
162
|
+
{...pendingAttributes}
|
|
235
163
|
{...rest}
|
|
236
164
|
style={resolvedStyle}
|
|
237
165
|
onclick={onClick}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { BaseLinkProps } from 'void/pages-client';
|
|
3
|
+
|
|
4
|
+
type EventHandler<T extends Event> = (event: T) => void;
|
|
5
|
+
|
|
6
|
+
export type LinkProps = BaseLinkProps & {
|
|
7
|
+
children?: Snippet;
|
|
8
|
+
onclick?: EventHandler<MouseEvent>;
|
|
9
|
+
onmouseenter?: EventHandler<MouseEvent>;
|
|
10
|
+
onmouseleave?: EventHandler<MouseEvent>;
|
|
11
|
+
onfocus?: EventHandler<FocusEvent>;
|
|
12
|
+
onmousedown?: EventHandler<MouseEvent>;
|
|
13
|
+
ontouchstart?: EventHandler<TouchEvent>;
|
|
14
|
+
style?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
@@ -9,65 +9,32 @@ import {
|
|
|
9
9
|
parseFormResetArgs,
|
|
10
10
|
submitAction,
|
|
11
11
|
type FormResetOptions,
|
|
12
|
-
type
|
|
12
|
+
type FormState,
|
|
13
|
+
type FormSubmitOptions,
|
|
13
14
|
type VoidRouter,
|
|
14
15
|
} from 'void/pages-client';
|
|
15
16
|
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
|
|
16
17
|
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
readonly pending: boolean;
|
|
25
|
-
readonly hasChanges: boolean;
|
|
26
|
-
readonly wasSuccessful: boolean;
|
|
27
|
-
readonly recentlySuccessful: boolean;
|
|
28
|
-
post(opts?: FormSubmitOptions): Promise<void>;
|
|
29
|
-
put(opts?: FormSubmitOptions): Promise<void>;
|
|
30
|
-
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
31
|
-
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
32
|
-
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
|
|
33
|
-
reset(...fields: ReadonlyArray<string>): void;
|
|
34
|
-
setError(field: string, message: string): void;
|
|
35
|
-
clearErrors(...fields: ReadonlyArray<string>): void;
|
|
36
|
-
clearError(): void;
|
|
37
|
-
};
|
|
18
|
+
type FormReturn<TData> = FormState<TData, 'in-place'> &
|
|
19
|
+
Readonly<{
|
|
20
|
+
post(opts?: FormSubmitOptions): Promise<void>;
|
|
21
|
+
put(opts?: FormSubmitOptions): Promise<void>;
|
|
22
|
+
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
23
|
+
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
24
|
+
}>;
|
|
38
25
|
|
|
39
26
|
// Typed overload — full inference from PageActionMap (requires codegen)
|
|
40
27
|
export function useForm<U extends ActionUrl & string>(
|
|
41
28
|
url: U,
|
|
42
29
|
defaults: ResolveActionBody<U>,
|
|
43
30
|
options?: ActionFormOptions<U>,
|
|
44
|
-
):
|
|
45
|
-
readonly data: ResolveActionBody<U>;
|
|
46
|
-
readonly errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
|
|
47
|
-
readonly error: VoidActionError | null;
|
|
48
|
-
readonly pending: boolean;
|
|
49
|
-
readonly hasChanges: boolean;
|
|
50
|
-
readonly wasSuccessful: boolean;
|
|
51
|
-
readonly recentlySuccessful: boolean;
|
|
52
|
-
post(opts?: FormSubmitOptions): Promise<void>;
|
|
53
|
-
put(opts?: FormSubmitOptions): Promise<void>;
|
|
54
|
-
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
55
|
-
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
56
|
-
reset(
|
|
57
|
-
options: FormResetOptions,
|
|
58
|
-
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
|
|
59
|
-
): void;
|
|
60
|
-
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
|
|
61
|
-
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
|
|
62
|
-
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
|
|
63
|
-
clearError(): void;
|
|
64
|
-
};
|
|
31
|
+
): FormReturn<ResolveActionBody<U>>;
|
|
65
32
|
|
|
66
33
|
export function useForm(
|
|
67
34
|
url: string,
|
|
68
35
|
defaults: Record<string, unknown>,
|
|
69
36
|
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
|
|
70
|
-
):
|
|
37
|
+
): FormReturn<Record<string, unknown>> {
|
|
71
38
|
const router = getContext<VoidRouter>('__void_router');
|
|
72
39
|
|
|
73
40
|
const initialDefaults = cloneFormValue(defaults);
|
|
@@ -95,6 +62,26 @@ export function useForm(
|
|
|
95
62
|
});
|
|
96
63
|
});
|
|
97
64
|
|
|
65
|
+
// An input handler may validate its new value before the change effect runs.
|
|
66
|
+
function snapshotField(field: string) {
|
|
67
|
+
untrack(() => {
|
|
68
|
+
dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handlers may also write `form.errors.field` directly, which needs the same snapshot.
|
|
73
|
+
// The view is derived because setError and clearErrors replace `errors`.
|
|
74
|
+
const errorsView = $derived(
|
|
75
|
+
new Proxy(errors, {
|
|
76
|
+
set(target, field, message) {
|
|
77
|
+
if (typeof field === 'string') {
|
|
78
|
+
snapshotField(field);
|
|
79
|
+
}
|
|
80
|
+
return Reflect.set(target, field, message);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
98
85
|
const hasChanges = $derived(
|
|
99
86
|
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
|
|
100
87
|
);
|
|
@@ -123,8 +110,7 @@ export function useForm(
|
|
|
123
110
|
}
|
|
124
111
|
|
|
125
112
|
function setError(field: string, message: string) {
|
|
126
|
-
|
|
127
|
-
dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
|
|
113
|
+
snapshotField(field);
|
|
128
114
|
errors = { ...errors, [field]: message };
|
|
129
115
|
}
|
|
130
116
|
|
|
@@ -189,7 +175,7 @@ export function useForm(
|
|
|
189
175
|
return data;
|
|
190
176
|
},
|
|
191
177
|
get errors() {
|
|
192
|
-
return
|
|
178
|
+
return errorsView;
|
|
193
179
|
},
|
|
194
180
|
get error() {
|
|
195
181
|
return error;
|
|
@@ -1,29 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isEqualFormValue,
|
|
3
|
+
submitIslandAction,
|
|
4
|
+
type IslandFormState,
|
|
5
|
+
type VoidActionError,
|
|
6
|
+
} from 'void/pages-client';
|
|
2
7
|
|
|
3
|
-
type
|
|
4
|
-
|
|
5
|
-
readonly errors: Record<string, string>;
|
|
6
|
-
readonly error: VoidActionError | null;
|
|
7
|
-
readonly pending: boolean;
|
|
8
|
-
readonly hasChanges: boolean;
|
|
9
|
-
readonly wasSuccessful: boolean;
|
|
10
|
-
readonly recentlySuccessful: boolean;
|
|
11
|
-
submit(url: string, method: string): Promise<void>;
|
|
12
|
-
post(url: string): Promise<void>;
|
|
13
|
-
put(url: string): Promise<void>;
|
|
14
|
-
patch(url: string): Promise<void>;
|
|
15
|
-
delete(url: string): Promise<void>;
|
|
16
|
-
reset(...fields: ReadonlyArray<string>): void;
|
|
17
|
-
clearErrors(...fields: ReadonlyArray<string>): void;
|
|
18
|
-
clearError(): void;
|
|
19
|
-
};
|
|
8
|
+
export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'in-place'> &
|
|
9
|
+
Readonly<{ submit(url: string, method: string): Promise<void> }>;
|
|
20
10
|
|
|
21
11
|
/**
|
|
22
12
|
* Form composable for island pages. Uses fetch + page reload instead of the
|
|
23
13
|
* Void Router (which is not available in island mode).
|
|
24
14
|
*/
|
|
25
|
-
export function useIslandForm
|
|
26
|
-
let data:
|
|
15
|
+
export function useIslandForm<T extends Record<string, unknown>>(defaults: T): IslandFormReturn<T> {
|
|
16
|
+
let data: T = $state({ ...defaults });
|
|
27
17
|
let errors: Record<string, string> = $state({});
|
|
28
18
|
let error: VoidActionError | null = $state(null);
|
|
29
19
|
let pending = $state(false);
|
|
@@ -37,7 +27,7 @@ export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandF
|
|
|
37
27
|
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
|
|
38
28
|
);
|
|
39
29
|
|
|
40
|
-
function reset(...fields: ReadonlyArray<string>) {
|
|
30
|
+
function reset(...fields: ReadonlyArray<keyof T & string>) {
|
|
41
31
|
if (fields.length === 0) {
|
|
42
32
|
data = { ...defaults_ };
|
|
43
33
|
} else {
|