@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @fuzdev/fuz_app
2
2
 
3
- > fullstack app library ⚠️ pre-alpha, do not use in production
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
- fuz_app is part of the [Fuz stack](https://www.fuz.dev/),
18
- for more see the <a href="https://github.com/fuzdev/fuz_app/discussions">fuz_app discussions</a>.
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" style:gap="var(--space_md)" style:align-items="center">
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
- <div class="row gap_sm mb_sm">
65
+ <fieldset class="row gap_sm">
66
+ <legend>invite target</legend>
66
67
  <label class="grow">
67
- <span class="text_50 font_size_sm">email</span>
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
- <span class="text_50 font_size_sm">username</span>
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
- </div>
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":"AAyIA,QAAA,MAAM,YAAY,2DAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
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 {enter_advance} from './enter_advance.js';
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
- if (!can_submit) return;
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 enter_advance()}
51
+ {@attach form_state.form()}
43
52
  >
44
- <label class="display:block mb_sm">
45
- <span class="text_50 font_size_sm">bootstrap token</span>
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 class="display:block mb_sm">
55
- <span class="text_50 font_size_sm">username</span>
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
- <label class="display:block mb_sm">
70
- <span class="text_50 font_size_sm">password (min {PASSWORD_LENGTH_MIN} characters)</span>
71
- <input
72
- type="password"
73
- bind:value={password}
74
- placeholder="password"
75
- autocomplete="new-password"
76
- disabled={auth_state.verifying}
77
- />
78
- </label>
79
- <label class="display:block mb_sm">
80
- <span class="text_50 font_size_sm">confirm password</span>
81
- <input
82
- type="password"
83
- bind:value={password_confirm}
84
- placeholder="confirm password"
85
- autocomplete="new-password"
86
- disabled={auth_state.verifying}
87
- />
88
- </label>
89
- {#if password && password_confirm && !passwords_match}
90
- <p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
91
- {/if}
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={!can_submit}
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":"AAqFA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
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"}
@@ -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 {enter_advance} from './enter_advance.js';
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 && p) {
27
- const success = await auth_state.login(u, p);
28
- if (success) {
29
- username = '';
30
- password = '';
31
- await goto(redirect_on_login);
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 enter_advance()}
54
+ {@attach form_state.form()}
47
55
  >
48
- <label class="display:block mb_sm">
49
- <span class="text_50 font_size_sm">{username_label}</span>
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 class="display:block mb_sm">
60
- <span class="text_50 font_size_sm">password</span>
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={!username.trim() || !password}
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;AA4DH,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,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 {enter_advance} from './enter_advance.js';
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
- if (!can_submit) return;
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 enter_advance()}
61
+ {@attach form_state.form()}
54
62
  >
55
- <label class="display:block mb_sm">
56
- <span class="text_50 font_size_sm">username</span>
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 class="display:block mb_sm">
72
- <span class="text_50 font_size_sm">email (optional)</span>
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
- <label class="display:block mb_sm">
82
- <span class="text_50 font_size_sm">password (min {PASSWORD_LENGTH_MIN} characters)</span>
83
- <input
84
- type="password"
85
- bind:value={password}
86
- placeholder="password"
87
- autocomplete="new-password"
88
- disabled={auth_state.verifying}
89
- />
90
- </label>
91
- <label class="display:block mb_sm">
92
- <span class="text_50 font_size_sm">confirm password</span>
93
- <input
94
- type="password"
95
- bind:value={password_confirm}
96
- placeholder="confirm password"
97
- autocomplete="new-password"
98
- disabled={auth_state.verifying}
99
- />
100
- </label>
101
- {#if password && password_confirm && !passwords_match}
102
- <p class="color_c_50 font_size_sm mt_0 mb_xs">passwords do not match</p>
103
- {/if}
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={!can_submit}
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;AAoFH,QAAA,MAAM,UAAU,sDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,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"}
@@ -76,7 +76,7 @@
76
76
  <h3>routes</h3>
77
77
  <div class="mb_sm">
78
78
  <label>
79
- auth filter
79
+ <div class="title">auth filter</div>
80
80
  <select bind:value={auth_filter}>
81
81
  {#each auth_types as t (t)}
82
82
  <option value={t}>{t}</option>
@@ -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.1.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.57.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.1",
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"}
@@ -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
- };