@fuzdev/fuz_app 0.1.0 → 0.2.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 +4 -3
- package/dist/ui/AdminAuditLog.svelte +1 -1
- package/dist/ui/AdminInvites.svelte +5 -4
- package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
- package/dist/ui/BootstrapForm.svelte +54 -33
- package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
- package/dist/ui/LoginForm.svelte +24 -14
- package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
- package/dist/ui/SignupForm.svelte +52 -32
- package/dist/ui/SignupForm.svelte.d.ts.map +1 -1
- package/dist/ui/SurfaceExplorer.svelte +1 -1
- package/dist/ui/form_state.svelte.d.ts +83 -0
- package/dist/ui/form_state.svelte.d.ts.map +1 -0
- package/dist/ui/form_state.svelte.js +148 -0
- package/package.json +4 -4
- package/dist/ui/enter_advance.d.ts +0 -13
- package/dist/ui/enter_advance.d.ts.map +0 -1
- package/dist/ui/enter_advance.js +0 -30
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @fuzdev/fuz_app
|
|
2
2
|
|
|
3
|
-
> fullstack app library
|
|
3
|
+
> fullstack app library 🗝 pre-alpha ⚠️ do not use in production
|
|
4
4
|
|
|
5
5
|
fuz_app is a fullstack app library for
|
|
6
6
|
TypeScript, [Svelte](https://svelte.dev/), SvelteKit,
|
|
@@ -14,8 +14,9 @@ fuz_app supports deploying with Deno, Node, and Bun,
|
|
|
14
14
|
to servers, static websites, and local-first binaries, with more to come,
|
|
15
15
|
eventually with compatible alternatives written in Rust.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
For more see the <a href="https://github.com/fuzdev/fuz_app/discussions">discussions</a>.
|
|
18
|
+
fuz_app is part of the Fuz stack
|
|
19
|
+
([fuz.dev](https://www.fuz.dev/), [@fuzdev](https://github.com/fuzdev)).
|
|
19
20
|
|
|
20
21
|
> ⚠️ This is a pre-alpha release, not ready for production.
|
|
21
22
|
> There are no known security vulnerabilities,
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
<section>
|
|
62
62
|
<h1>audit log</h1>
|
|
63
63
|
|
|
64
|
-
<div class="row mb_md
|
|
64
|
+
<div class="row mb_md gap_md" style:align-items="end">
|
|
65
65
|
<label class="mb_0">
|
|
66
66
|
<div class="title">filter</div>
|
|
67
67
|
<select bind:value={filter_event_type} onchange={handle_filter_change}>
|
|
@@ -62,9 +62,10 @@
|
|
|
62
62
|
void handle_create();
|
|
63
63
|
}}
|
|
64
64
|
>
|
|
65
|
-
<
|
|
65
|
+
<fieldset class="row gap_sm">
|
|
66
|
+
<legend>invite target</legend>
|
|
66
67
|
<label class="grow">
|
|
67
|
-
<
|
|
68
|
+
<div class="title">email</div>
|
|
68
69
|
<input
|
|
69
70
|
type="email"
|
|
70
71
|
bind:value={invite_email}
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
/>
|
|
74
75
|
</label>
|
|
75
76
|
<label class="grow">
|
|
76
|
-
<
|
|
77
|
+
<div class="title">username</div>
|
|
77
78
|
<input
|
|
78
79
|
type="text"
|
|
79
80
|
bind:value={invite_username}
|
|
@@ -81,7 +82,7 @@
|
|
|
81
82
|
disabled={admin_invites.creating}
|
|
82
83
|
/>
|
|
83
84
|
</label>
|
|
84
|
-
</
|
|
85
|
+
</fieldset>
|
|
85
86
|
<PendingButton pending={admin_invites.creating} disabled={!can_create} onclick={handle_create}>
|
|
86
87
|
create invite
|
|
87
88
|
</PendingButton>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminInvites.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminInvites.svelte"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"AdminInvites.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminInvites.svelte"],"names":[],"mappings":"AA0IA,QAAA,MAAM,YAAY,2DAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
import {Username} from '../auth/account_schema.js';
|
|
6
6
|
import {PASSWORD_LENGTH_MIN} from '../auth/password.js';
|
|
7
7
|
import {auth_state_context} from './auth_state.svelte.js';
|
|
8
|
-
import {
|
|
8
|
+
import {FormState} from './form_state.svelte.js';
|
|
9
9
|
|
|
10
10
|
const auth_state = auth_state_context.get();
|
|
11
|
+
const form_state = new FormState();
|
|
11
12
|
|
|
12
13
|
let token = $state('');
|
|
13
14
|
let username = $state('');
|
|
@@ -21,11 +22,19 @@
|
|
|
21
22
|
username.trim() &&
|
|
22
23
|
username_valid &&
|
|
23
24
|
password.length >= PASSWORD_LENGTH_MIN &&
|
|
24
|
-
passwords_match
|
|
25
|
+
passwords_match &&
|
|
26
|
+
!auth_state.verifying,
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
const handle_bootstrap = async (): Promise<void> => {
|
|
28
|
-
|
|
30
|
+
form_state.attempt();
|
|
31
|
+
if (!can_submit) {
|
|
32
|
+
if (!token.trim()) form_state.focus('token');
|
|
33
|
+
else if (!username.trim() || !username_valid) form_state.focus('username');
|
|
34
|
+
else if (password.length < PASSWORD_LENGTH_MIN) form_state.focus('password');
|
|
35
|
+
else if (!passwords_match) form_state.focus('password_confirm');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
29
38
|
await auth_state.bootstrap(token.trim(), username.trim(), password);
|
|
30
39
|
};
|
|
31
40
|
</script>
|
|
@@ -39,11 +48,12 @@
|
|
|
39
48
|
e.preventDefault();
|
|
40
49
|
void handle_bootstrap();
|
|
41
50
|
}}
|
|
42
|
-
{@attach
|
|
51
|
+
{@attach form_state.form()}
|
|
43
52
|
>
|
|
44
|
-
<label
|
|
45
|
-
<
|
|
53
|
+
<label>
|
|
54
|
+
<div class="title">bootstrap token</div>
|
|
46
55
|
<input
|
|
56
|
+
name="token"
|
|
47
57
|
type="password"
|
|
48
58
|
bind:value={token}
|
|
49
59
|
placeholder="paste token"
|
|
@@ -51,9 +61,10 @@
|
|
|
51
61
|
{@attach autofocus()}
|
|
52
62
|
/>
|
|
53
63
|
</label>
|
|
54
|
-
<label
|
|
55
|
-
<
|
|
64
|
+
<label>
|
|
65
|
+
<div class="title">username</div>
|
|
56
66
|
<input
|
|
67
|
+
name="username"
|
|
57
68
|
type="text"
|
|
58
69
|
bind:value={username}
|
|
59
70
|
placeholder="admin"
|
|
@@ -61,38 +72,48 @@
|
|
|
61
72
|
disabled={auth_state.verifying}
|
|
62
73
|
/>
|
|
63
74
|
</label>
|
|
64
|
-
{#if username && !username_valid}
|
|
75
|
+
{#if form_state.show('username') && username && !username_valid}
|
|
65
76
|
<p class="color_c_50 font_size_sm mt_0 mb_xs">
|
|
66
77
|
3-39 chars, starts with a letter, ends with letter/number, middle allows dash/underscore
|
|
67
78
|
</p>
|
|
68
79
|
{/if}
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
<fieldset>
|
|
81
|
+
<legend>password</legend>
|
|
82
|
+
<label>
|
|
83
|
+
<div class="title">password (min {PASSWORD_LENGTH_MIN} characters)</div>
|
|
84
|
+
<input
|
|
85
|
+
name="password"
|
|
86
|
+
type="password"
|
|
87
|
+
bind:value={password}
|
|
88
|
+
placeholder="password"
|
|
89
|
+
autocomplete="new-password"
|
|
90
|
+
disabled={auth_state.verifying}
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
{#if form_state.show('password') && password && password.length < PASSWORD_LENGTH_MIN}
|
|
94
|
+
<p class="color_c_50 font_size_sm mt_0 mb_xs">
|
|
95
|
+
password must be at least {PASSWORD_LENGTH_MIN} characters
|
|
96
|
+
</p>
|
|
97
|
+
{/if}
|
|
98
|
+
<label>
|
|
99
|
+
<div class="title">confirm password</div>
|
|
100
|
+
<input
|
|
101
|
+
name="password_confirm"
|
|
102
|
+
type="password"
|
|
103
|
+
bind:value={password_confirm}
|
|
104
|
+
placeholder="confirm password"
|
|
105
|
+
autocomplete="new-password"
|
|
106
|
+
disabled={auth_state.verifying}
|
|
107
|
+
/>
|
|
108
|
+
</label>
|
|
109
|
+
{#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
|
|
110
|
+
<p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
|
|
111
|
+
{/if}
|
|
112
|
+
</fieldset>
|
|
92
113
|
<div class="row gap_sm">
|
|
93
114
|
<PendingButton
|
|
94
115
|
pending={auth_state.verifying}
|
|
95
|
-
disabled={
|
|
116
|
+
disabled={auth_state.verifying}
|
|
96
117
|
onclick={handle_bootstrap}
|
|
97
118
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
98
119
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"AAsGA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
package/dist/ui/LoginForm.svelte
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import {autofocus} from '@fuzdev/fuz_ui/autofocus.svelte.js';
|
|
6
6
|
|
|
7
7
|
import {auth_state_context} from './auth_state.svelte.js';
|
|
8
|
-
import {
|
|
8
|
+
import {FormState} from './form_state.svelte.js';
|
|
9
9
|
|
|
10
10
|
const {
|
|
11
11
|
username_label = 'username or email',
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
} = $props();
|
|
17
17
|
|
|
18
18
|
const auth_state = auth_state_context.get();
|
|
19
|
+
const form_state = new FormState();
|
|
19
20
|
|
|
20
21
|
let username = $state('');
|
|
21
22
|
let password = $state('');
|
|
@@ -23,13 +24,20 @@
|
|
|
23
24
|
const handle_login = async (): Promise<void> => {
|
|
24
25
|
const u = username.trim();
|
|
25
26
|
const p = password;
|
|
26
|
-
if (u
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
if (!u) {
|
|
28
|
+
form_state.focus('username');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!p) {
|
|
32
|
+
form_state.focus('password');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const success = await auth_state.login(u, p);
|
|
36
|
+
if (success) {
|
|
37
|
+
form_state.reset();
|
|
38
|
+
username = '';
|
|
39
|
+
password = '';
|
|
40
|
+
await goto(redirect_on_login);
|
|
33
41
|
}
|
|
34
42
|
};
|
|
35
43
|
</script>
|
|
@@ -43,11 +51,12 @@
|
|
|
43
51
|
e.preventDefault();
|
|
44
52
|
void handle_login();
|
|
45
53
|
}}
|
|
46
|
-
{@attach
|
|
54
|
+
{@attach form_state.form()}
|
|
47
55
|
>
|
|
48
|
-
<label
|
|
49
|
-
<
|
|
56
|
+
<label>
|
|
57
|
+
<div class="title">{username_label}</div>
|
|
50
58
|
<input
|
|
59
|
+
name="username"
|
|
51
60
|
type="text"
|
|
52
61
|
bind:value={username}
|
|
53
62
|
placeholder={username_label}
|
|
@@ -56,9 +65,10 @@
|
|
|
56
65
|
{@attach autofocus()}
|
|
57
66
|
/>
|
|
58
67
|
</label>
|
|
59
|
-
<label
|
|
60
|
-
<
|
|
68
|
+
<label>
|
|
69
|
+
<div class="title">password</div>
|
|
61
70
|
<input
|
|
71
|
+
name="password"
|
|
62
72
|
type="password"
|
|
63
73
|
bind:value={password}
|
|
64
74
|
placeholder="password"
|
|
@@ -69,7 +79,7 @@
|
|
|
69
79
|
<div class="row gap_sm">
|
|
70
80
|
<PendingButton
|
|
71
81
|
pending={auth_state.verifying}
|
|
72
|
-
disabled={
|
|
82
|
+
disabled={auth_state.verifying}
|
|
73
83
|
onclick={handle_login}
|
|
74
84
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
75
85
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LoginForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/LoginForm.svelte"],"names":[],"mappings":"AAWC,KAAK,gBAAgB,GAAI;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;
|
|
1
|
+
{"version":3,"file":"LoginForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/LoginForm.svelte"],"names":[],"mappings":"AAWC,KAAK,gBAAgB,GAAI;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAoEH,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import {Username} from '../auth/account_schema.js';
|
|
8
8
|
import {PASSWORD_LENGTH_MIN} from '../auth/password.js';
|
|
9
9
|
import {auth_state_context} from './auth_state.svelte.js';
|
|
10
|
-
import {
|
|
10
|
+
import {FormState} from './form_state.svelte.js';
|
|
11
11
|
|
|
12
12
|
const {
|
|
13
13
|
redirect_on_signup = resolve('/account' as any),
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
} = $props();
|
|
17
17
|
|
|
18
18
|
const auth_state = auth_state_context.get();
|
|
19
|
+
const form_state = new FormState();
|
|
19
20
|
|
|
20
21
|
let username = $state('');
|
|
21
22
|
let email = $state('');
|
|
@@ -33,9 +34,16 @@
|
|
|
33
34
|
);
|
|
34
35
|
|
|
35
36
|
const handle_signup = async (): Promise<void> => {
|
|
36
|
-
|
|
37
|
+
form_state.attempt();
|
|
38
|
+
if (!can_submit) {
|
|
39
|
+
if (!username.trim() || !username_valid) form_state.focus('username');
|
|
40
|
+
else if (password.length < PASSWORD_LENGTH_MIN) form_state.focus('password');
|
|
41
|
+
else if (!passwords_match) form_state.focus('password_confirm');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
37
44
|
const success = await auth_state.signup(username.trim(), password, email.trim() || undefined);
|
|
38
45
|
if (success) {
|
|
46
|
+
form_state.reset();
|
|
39
47
|
await goto(redirect_on_signup);
|
|
40
48
|
}
|
|
41
49
|
};
|
|
@@ -50,11 +58,12 @@
|
|
|
50
58
|
e.preventDefault();
|
|
51
59
|
void handle_signup();
|
|
52
60
|
}}
|
|
53
|
-
{@attach
|
|
61
|
+
{@attach form_state.form()}
|
|
54
62
|
>
|
|
55
|
-
<label
|
|
56
|
-
<
|
|
63
|
+
<label>
|
|
64
|
+
<div class="title">username</div>
|
|
57
65
|
<input
|
|
66
|
+
name="username"
|
|
58
67
|
type="text"
|
|
59
68
|
bind:value={username}
|
|
60
69
|
placeholder="username"
|
|
@@ -63,14 +72,15 @@
|
|
|
63
72
|
{@attach autofocus()}
|
|
64
73
|
/>
|
|
65
74
|
</label>
|
|
66
|
-
{#if username && !username_valid}
|
|
75
|
+
{#if form_state.show('username') && username && !username_valid}
|
|
67
76
|
<p class="color_c_50 font_size_sm mt_0 mb_xs">
|
|
68
77
|
3-39 chars, starts with a letter, ends with letter/number, middle allows dash/underscore
|
|
69
78
|
</p>
|
|
70
79
|
{/if}
|
|
71
|
-
<label
|
|
72
|
-
<
|
|
80
|
+
<label>
|
|
81
|
+
<div class="title">email (optional)</div>
|
|
73
82
|
<input
|
|
83
|
+
name="email"
|
|
74
84
|
type="email"
|
|
75
85
|
bind:value={email}
|
|
76
86
|
placeholder="email"
|
|
@@ -78,33 +88,43 @@
|
|
|
78
88
|
disabled={auth_state.verifying}
|
|
79
89
|
/>
|
|
80
90
|
</label>
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
<fieldset>
|
|
92
|
+
<legend>password</legend>
|
|
93
|
+
<label>
|
|
94
|
+
<div class="title">password (min {PASSWORD_LENGTH_MIN} characters)</div>
|
|
95
|
+
<input
|
|
96
|
+
name="password"
|
|
97
|
+
type="password"
|
|
98
|
+
bind:value={password}
|
|
99
|
+
placeholder="password"
|
|
100
|
+
autocomplete="new-password"
|
|
101
|
+
disabled={auth_state.verifying}
|
|
102
|
+
/>
|
|
103
|
+
</label>
|
|
104
|
+
{#if form_state.show('password') && password && password.length < PASSWORD_LENGTH_MIN}
|
|
105
|
+
<p class="color_c_50 font_size_sm mt_0 mb_xs">
|
|
106
|
+
password must be at least {PASSWORD_LENGTH_MIN} characters
|
|
107
|
+
</p>
|
|
108
|
+
{/if}
|
|
109
|
+
<label>
|
|
110
|
+
<div class="title">confirm password</div>
|
|
111
|
+
<input
|
|
112
|
+
name="password_confirm"
|
|
113
|
+
type="password"
|
|
114
|
+
bind:value={password_confirm}
|
|
115
|
+
placeholder="confirm password"
|
|
116
|
+
autocomplete="new-password"
|
|
117
|
+
disabled={auth_state.verifying}
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
120
|
+
{#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
|
|
121
|
+
<p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
|
|
122
|
+
{/if}
|
|
123
|
+
</fieldset>
|
|
104
124
|
<div class="row gap_sm">
|
|
105
125
|
<PendingButton
|
|
106
126
|
pending={auth_state.verifying}
|
|
107
|
-
disabled={
|
|
127
|
+
disabled={auth_state.verifying}
|
|
108
128
|
onclick={handle_signup}
|
|
109
129
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
110
130
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SignupForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/SignupForm.svelte"],"names":[],"mappings":"AAaC,KAAK,gBAAgB,GAAI;IACxB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;
|
|
1
|
+
{"version":3,"file":"SignupForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/SignupForm.svelte"],"names":[],"mappings":"AAaC,KAAK,gBAAgB,GAAI;IACxB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAoGH,QAAA,MAAM,UAAU,sDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive form state for controlling when field errors appear and focusing invalid fields.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-field `touched` state (set on blur via delegated `focusout`) and form-level
|
|
5
|
+
* `attempted` state (set on submit attempt). Errors show after a field is blurred or
|
|
6
|
+
* after a submit attempt, avoiding premature validation while the user is still typing.
|
|
7
|
+
*
|
|
8
|
+
* The {@link FormState.form | form} attachment also handles Enter key advancing
|
|
9
|
+
* between focusable elements.
|
|
10
|
+
*
|
|
11
|
+
* All trackable inputs must have a `name` attribute — an error is thrown in dev
|
|
12
|
+
* if an input without `name` loses focus.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```svelte
|
|
16
|
+
* <script>
|
|
17
|
+
* const form_state = new FormState();
|
|
18
|
+
* let username = $state('');
|
|
19
|
+
* const username_valid = $derived(Username.safeParse(username).success);
|
|
20
|
+
* const can_submit = $derived(username.trim() && username_valid);
|
|
21
|
+
*
|
|
22
|
+
* const handle_submit = async () => {
|
|
23
|
+
* form_state.attempt();
|
|
24
|
+
* if (!can_submit) {
|
|
25
|
+
* if (!username.trim() || !username_valid) form_state.focus('username');
|
|
26
|
+
* return;
|
|
27
|
+
* }
|
|
28
|
+
* // submit...
|
|
29
|
+
* };
|
|
30
|
+
* </script>
|
|
31
|
+
*
|
|
32
|
+
* <form {@attach form_state.form()} onsubmit={(e) => { e.preventDefault(); void handle_submit(); }}>
|
|
33
|
+
* <input name="username" bind:value={username} />
|
|
34
|
+
* {#if form_state.show('username') && username && !username_valid}
|
|
35
|
+
* <p>error message</p>
|
|
36
|
+
* {/if}
|
|
37
|
+
* <PendingButton onclick={handle_submit}>submit</PendingButton>
|
|
38
|
+
* </form>
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
import type { Attachment } from 'svelte/attachments';
|
|
44
|
+
export declare class FormState {
|
|
45
|
+
#private;
|
|
46
|
+
/**
|
|
47
|
+
* Whether a submit attempt has been made.
|
|
48
|
+
*/
|
|
49
|
+
get attempted(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Creates a form attachment that handles Enter key advancing between
|
|
52
|
+
* focusable elements and tracks field touched state via delegated `focusout`.
|
|
53
|
+
*
|
|
54
|
+
* Fields are identified by their `name` attribute.
|
|
55
|
+
*/
|
|
56
|
+
form(): Attachment<HTMLFormElement>;
|
|
57
|
+
/**
|
|
58
|
+
* Whether a field has been blurred at least once.
|
|
59
|
+
*/
|
|
60
|
+
is_touched(field: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Whether to show validation errors for a field.
|
|
63
|
+
* Returns `true` if the field has been blurred or a submit attempt was made.
|
|
64
|
+
*/
|
|
65
|
+
show(field: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Programmatically marks a field as touched without requiring a blur event.
|
|
68
|
+
*/
|
|
69
|
+
touch(field: string): void;
|
|
70
|
+
/**
|
|
71
|
+
* Focuses the named input within the form.
|
|
72
|
+
*/
|
|
73
|
+
focus(field: string): void;
|
|
74
|
+
/**
|
|
75
|
+
* Marks the form as having been submitted, causing all field errors to show.
|
|
76
|
+
*/
|
|
77
|
+
attempt(): void;
|
|
78
|
+
/**
|
|
79
|
+
* Resets all touched and attempted state.
|
|
80
|
+
*/
|
|
81
|
+
reset(): void;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=form_state.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/form_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AASnD,qBAAa,SAAS;;IAKrB;;OAEG;IACH,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED;;;;;OAKG;IACH,IAAI,IAAI,UAAU,CAAC,eAAe,CAAC;IAuCnC;;OAEG;IACH,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAIlC;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAI5B;;OAEG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI1B;;OAEG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAe1B;;OAEG;IACH,OAAO,IAAI,IAAI;IAIf;;OAEG;IACH,KAAK,IAAI,IAAI;CAIb"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive form state for controlling when field errors appear and focusing invalid fields.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-field `touched` state (set on blur via delegated `focusout`) and form-level
|
|
5
|
+
* `attempted` state (set on submit attempt). Errors show after a field is blurred or
|
|
6
|
+
* after a submit attempt, avoiding premature validation while the user is still typing.
|
|
7
|
+
*
|
|
8
|
+
* The {@link FormState.form | form} attachment also handles Enter key advancing
|
|
9
|
+
* between focusable elements.
|
|
10
|
+
*
|
|
11
|
+
* All trackable inputs must have a `name` attribute — an error is thrown in dev
|
|
12
|
+
* if an input without `name` loses focus.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```svelte
|
|
16
|
+
* <script>
|
|
17
|
+
* const form_state = new FormState();
|
|
18
|
+
* let username = $state('');
|
|
19
|
+
* const username_valid = $derived(Username.safeParse(username).success);
|
|
20
|
+
* const can_submit = $derived(username.trim() && username_valid);
|
|
21
|
+
*
|
|
22
|
+
* const handle_submit = async () => {
|
|
23
|
+
* form_state.attempt();
|
|
24
|
+
* if (!can_submit) {
|
|
25
|
+
* if (!username.trim() || !username_valid) form_state.focus('username');
|
|
26
|
+
* return;
|
|
27
|
+
* }
|
|
28
|
+
* // submit...
|
|
29
|
+
* };
|
|
30
|
+
* </script>
|
|
31
|
+
*
|
|
32
|
+
* <form {@attach form_state.form()} onsubmit={(e) => { e.preventDefault(); void handle_submit(); }}>
|
|
33
|
+
* <input name="username" bind:value={username} />
|
|
34
|
+
* {#if form_state.show('username') && username && !username_valid}
|
|
35
|
+
* <p>error message</p>
|
|
36
|
+
* {/if}
|
|
37
|
+
* <PendingButton onclick={handle_submit}>submit</PendingButton>
|
|
38
|
+
* </form>
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
import { DEV } from 'esm-env';
|
|
44
|
+
import { on } from 'svelte/events';
|
|
45
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
46
|
+
const FOCUSABLE_SELECTOR = 'input:not(:disabled), button:not(:disabled)';
|
|
47
|
+
const FORM_INPUT_SELECTOR = 'input, textarea, select';
|
|
48
|
+
export class FormState {
|
|
49
|
+
#touched = new SvelteSet();
|
|
50
|
+
#form = null;
|
|
51
|
+
#attempted = $state(false);
|
|
52
|
+
/**
|
|
53
|
+
* Whether a submit attempt has been made.
|
|
54
|
+
*/
|
|
55
|
+
get attempted() {
|
|
56
|
+
return this.#attempted;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Creates a form attachment that handles Enter key advancing between
|
|
60
|
+
* focusable elements and tracks field touched state via delegated `focusout`.
|
|
61
|
+
*
|
|
62
|
+
* Fields are identified by their `name` attribute.
|
|
63
|
+
*/
|
|
64
|
+
form() {
|
|
65
|
+
if (DEV && this.#form) {
|
|
66
|
+
throw new Error('FormState: form() called while already attached to a form.');
|
|
67
|
+
}
|
|
68
|
+
return (form) => {
|
|
69
|
+
this.#form = form;
|
|
70
|
+
const keydown_cleanup = on(form, 'keydown', (e) => {
|
|
71
|
+
if (e.key !== 'Enter')
|
|
72
|
+
return;
|
|
73
|
+
if (!(e.target instanceof HTMLInputElement))
|
|
74
|
+
return;
|
|
75
|
+
const elements = Array.from(form.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
76
|
+
const index = elements.indexOf(e.target);
|
|
77
|
+
if (index < 0)
|
|
78
|
+
return;
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
elements[(index + 1) % elements.length].focus();
|
|
81
|
+
});
|
|
82
|
+
const focusout_cleanup = on(form, 'focusout', (e) => {
|
|
83
|
+
const target = e.target;
|
|
84
|
+
if (target instanceof HTMLElement && target.matches(FORM_INPUT_SELECTOR)) {
|
|
85
|
+
const name = target.name;
|
|
86
|
+
if (DEV && !name) {
|
|
87
|
+
throw new Error('FormState: input missing name attribute. All inputs in a FormState form must have a name.');
|
|
88
|
+
}
|
|
89
|
+
if (name) {
|
|
90
|
+
this.#touched.add(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return () => {
|
|
95
|
+
keydown_cleanup();
|
|
96
|
+
focusout_cleanup();
|
|
97
|
+
this.#form = null;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether a field has been blurred at least once.
|
|
103
|
+
*/
|
|
104
|
+
is_touched(field) {
|
|
105
|
+
return this.#touched.has(field);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether to show validation errors for a field.
|
|
109
|
+
* Returns `true` if the field has been blurred or a submit attempt was made.
|
|
110
|
+
*/
|
|
111
|
+
show(field) {
|
|
112
|
+
return this.#touched.has(field) || this.#attempted;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Programmatically marks a field as touched without requiring a blur event.
|
|
116
|
+
*/
|
|
117
|
+
touch(field) {
|
|
118
|
+
this.#touched.add(field);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Focuses the named input within the form.
|
|
122
|
+
*/
|
|
123
|
+
focus(field) {
|
|
124
|
+
if (DEV && !this.#form) {
|
|
125
|
+
console.warn('FormState: focus() called before form() attachment is active.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const el = this.#form?.querySelector(`[name="${field}"]`);
|
|
129
|
+
if (DEV && !el) {
|
|
130
|
+
console.warn(`FormState: no element found with name="${field}". Check for typos in the name attribute or form_state.focus() call.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
el?.focus({ focusVisible: true });
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Marks the form as having been submitted, causing all field errors to show.
|
|
137
|
+
*/
|
|
138
|
+
attempt() {
|
|
139
|
+
this.#attempted = true;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Resets all touched and attempted state.
|
|
143
|
+
*/
|
|
144
|
+
reset() {
|
|
145
|
+
this.#touched.clear();
|
|
146
|
+
this.#attempted = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
|
-
"glyph": "
|
|
5
|
+
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
7
7
|
"logo_alt": "a friendly pixelated spider facing you",
|
|
8
8
|
"license": "MIT",
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"@electric-sql/pglite": "^0.3.16",
|
|
45
45
|
"@fuzdev/blake3_wasm": "^0.1.0",
|
|
46
46
|
"@fuzdev/fuz_code": "^0.45.1",
|
|
47
|
-
"@fuzdev/fuz_css": "^0.
|
|
47
|
+
"@fuzdev/fuz_css": "^0.58.0",
|
|
48
48
|
"@fuzdev/fuz_ui": "^0.191.1",
|
|
49
49
|
"@fuzdev/fuz_util": "^0.55.0",
|
|
50
|
-
"@fuzdev/gro": "^0.197.
|
|
50
|
+
"@fuzdev/gro": "^0.197.2",
|
|
51
51
|
"@jridgewell/trace-mapping": "^0.3.31",
|
|
52
52
|
"@node-rs/argon2": "^2.0.2",
|
|
53
53
|
"@ryanatkn/eslint-config": "^0.10.1",
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Svelte action that makes Enter advance to the next focusable form element
|
|
3
|
-
* (inputs and buttons), or activate the element if it's already the last one.
|
|
4
|
-
*
|
|
5
|
-
* @example
|
|
6
|
-
* ```svelte
|
|
7
|
-
* <form {@attach enter_advance()}>
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* @module
|
|
11
|
-
*/
|
|
12
|
-
export declare const enter_advance: () => ((form: HTMLFormElement) => () => void);
|
|
13
|
-
//# sourceMappingURL=enter_advance.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"enter_advance.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/enter_advance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,aAAa,QAAO,CAAC,CAAC,IAAI,EAAE,eAAe,KAAK,MAAM,IAAI,CAiBtE,CAAC"}
|
package/dist/ui/enter_advance.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Svelte action that makes Enter advance to the next focusable form element
|
|
3
|
-
* (inputs and buttons), or activate the element if it's already the last one.
|
|
4
|
-
*
|
|
5
|
-
* @example
|
|
6
|
-
* ```svelte
|
|
7
|
-
* <form {@attach enter_advance()}>
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* @module
|
|
11
|
-
*/
|
|
12
|
-
const FOCUSABLE_SELECTOR = 'input:not(:disabled), button:not(:disabled)';
|
|
13
|
-
export const enter_advance = () => {
|
|
14
|
-
return (form) => {
|
|
15
|
-
const handle_keydown = (e) => {
|
|
16
|
-
if (e.key !== 'Enter')
|
|
17
|
-
return;
|
|
18
|
-
if (!(e.target instanceof HTMLInputElement))
|
|
19
|
-
return;
|
|
20
|
-
const elements = Array.from(form.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
21
|
-
const index = elements.indexOf(e.target);
|
|
22
|
-
if (index < 0)
|
|
23
|
-
return;
|
|
24
|
-
e.preventDefault();
|
|
25
|
-
elements[(index + 1) % elements.length].focus();
|
|
26
|
-
};
|
|
27
|
-
form.addEventListener('keydown', handle_keydown);
|
|
28
|
-
return () => form.removeEventListener('keydown', handle_keydown);
|
|
29
|
-
};
|
|
30
|
-
};
|