@fuzdev/fuz_app 0.1.1 → 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/dist/ui/BootstrapForm.svelte +25 -7
- package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
- package/dist/ui/LoginForm.svelte +20 -10
- package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
- package/dist/ui/SignupForm.svelte +23 -6
- package/dist/ui/SignupForm.svelte.d.ts.map +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 +1 -1
- 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
|
@@ -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
53
|
<label>
|
|
45
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"
|
|
@@ -54,6 +64,7 @@
|
|
|
54
64
|
<label>
|
|
55
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,7 +72,7 @@
|
|
|
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>
|
|
@@ -71,6 +82,7 @@
|
|
|
71
82
|
<label>
|
|
72
83
|
<div class="title">password (min {PASSWORD_LENGTH_MIN} characters)</div>
|
|
73
84
|
<input
|
|
85
|
+
name="password"
|
|
74
86
|
type="password"
|
|
75
87
|
bind:value={password}
|
|
76
88
|
placeholder="password"
|
|
@@ -78,9 +90,15 @@
|
|
|
78
90
|
disabled={auth_state.verifying}
|
|
79
91
|
/>
|
|
80
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}
|
|
81
98
|
<label>
|
|
82
99
|
<div class="title">confirm password</div>
|
|
83
100
|
<input
|
|
101
|
+
name="password_confirm"
|
|
84
102
|
type="password"
|
|
85
103
|
bind:value={password_confirm}
|
|
86
104
|
placeholder="confirm password"
|
|
@@ -88,14 +106,14 @@
|
|
|
88
106
|
disabled={auth_state.verifying}
|
|
89
107
|
/>
|
|
90
108
|
</label>
|
|
91
|
-
{#if password && password_confirm && !passwords_match}
|
|
109
|
+
{#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
|
|
92
110
|
<p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
|
|
93
111
|
{/if}
|
|
94
112
|
</fieldset>
|
|
95
113
|
<div class="row gap_sm">
|
|
96
114
|
<PendingButton
|
|
97
115
|
pending={auth_state.verifying}
|
|
98
|
-
disabled={
|
|
116
|
+
disabled={auth_state.verifying}
|
|
99
117
|
onclick={handle_bootstrap}
|
|
100
118
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
101
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
56
|
<label>
|
|
49
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}
|
|
@@ -59,6 +68,7 @@
|
|
|
59
68
|
<label>
|
|
60
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
63
|
<label>
|
|
56
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,7 +72,7 @@
|
|
|
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>
|
|
@@ -71,6 +80,7 @@
|
|
|
71
80
|
<label>
|
|
72
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"
|
|
@@ -83,6 +93,7 @@
|
|
|
83
93
|
<label>
|
|
84
94
|
<div class="title">password (min {PASSWORD_LENGTH_MIN} characters)</div>
|
|
85
95
|
<input
|
|
96
|
+
name="password"
|
|
86
97
|
type="password"
|
|
87
98
|
bind:value={password}
|
|
88
99
|
placeholder="password"
|
|
@@ -90,9 +101,15 @@
|
|
|
90
101
|
disabled={auth_state.verifying}
|
|
91
102
|
/>
|
|
92
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}
|
|
93
109
|
<label>
|
|
94
110
|
<div class="title">confirm password</div>
|
|
95
111
|
<input
|
|
112
|
+
name="password_confirm"
|
|
96
113
|
type="password"
|
|
97
114
|
bind:value={password_confirm}
|
|
98
115
|
placeholder="confirm password"
|
|
@@ -100,14 +117,14 @@
|
|
|
100
117
|
disabled={auth_state.verifying}
|
|
101
118
|
/>
|
|
102
119
|
</label>
|
|
103
|
-
{#if password && password_confirm && !passwords_match}
|
|
120
|
+
{#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
|
|
104
121
|
<p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
|
|
105
122
|
{/if}
|
|
106
123
|
</fieldset>
|
|
107
124
|
<div class="row gap_sm">
|
|
108
125
|
<PendingButton
|
|
109
126
|
pending={auth_state.verifying}
|
|
110
|
-
disabled={
|
|
127
|
+
disabled={auth_state.verifying}
|
|
111
128
|
onclick={handle_signup}
|
|
112
129
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
113
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,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
|
-
};
|