@privaty/ui-forms 0.1.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +107 -0
- package/dist/components/form-error.svelte +49 -0
- package/dist/components/form-error.svelte.d.ts +15 -0
- package/dist/components/reset.svelte +49 -0
- package/dist/components/reset.svelte.d.ts +19 -0
- package/dist/components/submit.svelte +82 -0
- package/dist/components/submit.svelte.d.ts +27 -0
- package/dist/context.d.ts +23 -0
- package/dist/context.js +21 -0
- package/dist/form-state.svelte.d.ts +89 -0
- package/dist/form-state.svelte.js +139 -0
- package/dist/form.svelte +219 -0
- package/dist/form.svelte.d.ts +62 -0
- package/dist/inputs/checkbox-input.svelte +119 -0
- package/dist/inputs/checkbox-input.svelte.d.ts +45 -0
- package/dist/inputs/date-input.svelte +132 -0
- package/dist/inputs/date-input.svelte.d.ts +61 -0
- package/dist/inputs/number-input.svelte +141 -0
- package/dist/inputs/number-input.svelte.d.ts +63 -0
- package/dist/inputs/select-input.svelte +156 -0
- package/dist/inputs/select-input.svelte.d.ts +57 -0
- package/dist/inputs/text-input.svelte +126 -0
- package/dist/inputs/text-input.svelte.d.ts +59 -0
- package/dist/inputs/textarea-input.svelte +135 -0
- package/dist/inputs/textarea-input.svelte.d.ts +57 -0
- package/dist/inputs/wire-field.d.ts +44 -0
- package/dist/inputs/wire-field.js +45 -0
- package/dist/testing/fakes.svelte.d.ts +205 -0
- package/dist/testing/fakes.svelte.js +346 -0
- package/dist/types/field.d.ts +156 -0
- package/dist/types/field.js +1 -0
- package/dist/types/form.d.ts +27 -0
- package/dist/types/form.js +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { FieldRegistration } from "./types/field";
|
|
2
|
+
import type { ValidatableForm } from "./types/form";
|
|
3
|
+
/**
|
|
4
|
+
* Client-side display state for a form: dirty/touched tracking and
|
|
5
|
+
* error-display gating.
|
|
6
|
+
*
|
|
7
|
+
* Kit v3 ships field.dirty(), field.touched() and form `submitted`, but none
|
|
8
|
+
* substitute for this class (evaluated against the next.23 source):
|
|
9
|
+
* - Kit's dirty() is an edited-once FLAG that never clears when a value is
|
|
10
|
+
* edited back; isDirty here compares values, so edit-then-revert returns
|
|
11
|
+
* to pristine — which drives Submit's dirty-and-valid gate and Reset's
|
|
12
|
+
* disabled-while-pristine.
|
|
13
|
+
* - Kit's touched() flips on BLUR (and on set()); ours flips on input after
|
|
14
|
+
* validation, so issues appear while typing in the flash-safe order.
|
|
15
|
+
* - Kit's `submitted` only flips once a submission actually starts;
|
|
16
|
+
* submitAttempted must open the error gates on gate-BLOCKED attempts too.
|
|
17
|
+
*/
|
|
18
|
+
declare class FormState {
|
|
19
|
+
private form;
|
|
20
|
+
private fields;
|
|
21
|
+
private touched;
|
|
22
|
+
/**
|
|
23
|
+
* True once a submit has been attempted — including attempts a validation
|
|
24
|
+
* gate blocked before any submission started. Opens issue display for every
|
|
25
|
+
* field. Cleared by reset().
|
|
26
|
+
*/
|
|
27
|
+
submitAttempted: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The error the latest submission flow threw, or undefined. Cleared when
|
|
30
|
+
* the next submission flow starts and by reset().
|
|
31
|
+
*/
|
|
32
|
+
submitError: unknown;
|
|
33
|
+
/**
|
|
34
|
+
* True when the form has a client-side (preflight) schema, so validation
|
|
35
|
+
* triggered from here can stay off the network. Set by the Form component.
|
|
36
|
+
*/
|
|
37
|
+
clientOnlyValidation: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* True once every field has registered (the Form flips it on mount).
|
|
40
|
+
* Majority-dependent display like the required/optional markers waits for
|
|
41
|
+
* it — a partial registration set would render the wrong marker first.
|
|
42
|
+
*/
|
|
43
|
+
settled: boolean;
|
|
44
|
+
constructor(form: ValidatableForm);
|
|
45
|
+
/**
|
|
46
|
+
* True while any registered field's current value differs from its initial
|
|
47
|
+
* value (compared through the field's normalizer), so edit-then-revert
|
|
48
|
+
* returns to pristine.
|
|
49
|
+
*/
|
|
50
|
+
readonly isDirty: boolean;
|
|
51
|
+
/** True when the form currently has no issues — client- or server-produced. */
|
|
52
|
+
readonly isValid: boolean;
|
|
53
|
+
/** True while a submission is in flight (the form's `pending` count > 0). */
|
|
54
|
+
readonly isSubmitting: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* True when at least as many registered fields are required as optional.
|
|
57
|
+
* Drives the marker convention: only the minority kind gets a marker, so
|
|
58
|
+
* mostly-required forms mark the optional fields and vice versa.
|
|
59
|
+
*/
|
|
60
|
+
readonly majorityRequired: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Registers a field for dirty tracking, markers and reset. Returns the
|
|
63
|
+
* unregister function — pass it to onDestroy. Field names must be unique
|
|
64
|
+
* within a form; a duplicate name throws.
|
|
65
|
+
*/
|
|
66
|
+
register(field: FieldRegistration): () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Marks a field touched so shouldShowIssues() starts returning true for it.
|
|
69
|
+
* The Form calls this AFTER a validation pass settles, so a newly-touched
|
|
70
|
+
* field never flashes issues stale from the previous pass.
|
|
71
|
+
*/
|
|
72
|
+
markTouched(name: string): void;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the named field's issues may be displayed: everything shows once
|
|
75
|
+
* a submit was attempted, before that only touched fields do.
|
|
76
|
+
*/
|
|
77
|
+
shouldShowIssues(name: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Fire-and-forget full validation of the underlying form — preflight-only
|
|
80
|
+
* (off the network) when clientOnlyValidation is set.
|
|
81
|
+
*/
|
|
82
|
+
validate(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Restores every registered field to its initial value, clears touched and
|
|
85
|
+
* submit state, and revalidates so stale issues don't outlive the reset.
|
|
86
|
+
*/
|
|
87
|
+
reset(): void;
|
|
88
|
+
}
|
|
89
|
+
export { FormState };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
|
2
|
+
/**
|
|
3
|
+
* Client-side display state for a form: dirty/touched tracking and
|
|
4
|
+
* error-display gating.
|
|
5
|
+
*
|
|
6
|
+
* Kit v3 ships field.dirty(), field.touched() and form `submitted`, but none
|
|
7
|
+
* substitute for this class (evaluated against the next.23 source):
|
|
8
|
+
* - Kit's dirty() is an edited-once FLAG that never clears when a value is
|
|
9
|
+
* edited back; isDirty here compares values, so edit-then-revert returns
|
|
10
|
+
* to pristine — which drives Submit's dirty-and-valid gate and Reset's
|
|
11
|
+
* disabled-while-pristine.
|
|
12
|
+
* - Kit's touched() flips on BLUR (and on set()); ours flips on input after
|
|
13
|
+
* validation, so issues appear while typing in the flash-safe order.
|
|
14
|
+
* - Kit's `submitted` only flips once a submission actually starts;
|
|
15
|
+
* submitAttempted must open the error gates on gate-BLOCKED attempts too.
|
|
16
|
+
*/
|
|
17
|
+
class FormState {
|
|
18
|
+
form;
|
|
19
|
+
fields = new SvelteMap();
|
|
20
|
+
touched = new SvelteSet();
|
|
21
|
+
/**
|
|
22
|
+
* True once a submit has been attempted — including attempts a validation
|
|
23
|
+
* gate blocked before any submission started. Opens issue display for every
|
|
24
|
+
* field. Cleared by reset().
|
|
25
|
+
*/
|
|
26
|
+
submitAttempted = $state(false);
|
|
27
|
+
/**
|
|
28
|
+
* The error the latest submission flow threw, or undefined. Cleared when
|
|
29
|
+
* the next submission flow starts and by reset().
|
|
30
|
+
*/
|
|
31
|
+
submitError = $state();
|
|
32
|
+
/**
|
|
33
|
+
* True when the form has a client-side (preflight) schema, so validation
|
|
34
|
+
* triggered from here can stay off the network. Set by the Form component.
|
|
35
|
+
*/
|
|
36
|
+
clientOnlyValidation = false;
|
|
37
|
+
/**
|
|
38
|
+
* True once every field has registered (the Form flips it on mount).
|
|
39
|
+
* Majority-dependent display like the required/optional markers waits for
|
|
40
|
+
* it — a partial registration set would render the wrong marker first.
|
|
41
|
+
*/
|
|
42
|
+
settled = $state(false);
|
|
43
|
+
constructor(form) {
|
|
44
|
+
this.form = form;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True while any registered field's current value differs from its initial
|
|
48
|
+
* value (compared through the field's normalizer), so edit-then-revert
|
|
49
|
+
* returns to pristine.
|
|
50
|
+
*/
|
|
51
|
+
isDirty = $derived.by(() => {
|
|
52
|
+
for (const field of this.fields.values()) {
|
|
53
|
+
// Both sides pass through the field's normalizer: Kit stores raw DOM
|
|
54
|
+
// strings mid-edit while registrations hold typed seeds, so "5" vs 5
|
|
55
|
+
// and "on" vs true must compare equal. Only undefined means
|
|
56
|
+
// "untouched" — null is a real value (Kit's unchecked checkbox).
|
|
57
|
+
const raw = field.getValue();
|
|
58
|
+
const value = field.normalize(raw === undefined ? field.initialValue : raw);
|
|
59
|
+
if (value !== field.normalize(field.initialValue))
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
});
|
|
64
|
+
/** True when the form currently has no issues — client- or server-produced. */
|
|
65
|
+
isValid = $derived.by(() => !this.form.fields.allIssues()?.length);
|
|
66
|
+
/** True while a submission is in flight (the form's `pending` count > 0). */
|
|
67
|
+
isSubmitting = $derived.by(() => this.form.pending > 0);
|
|
68
|
+
/**
|
|
69
|
+
* True when at least as many registered fields are required as optional.
|
|
70
|
+
* Drives the marker convention: only the minority kind gets a marker, so
|
|
71
|
+
* mostly-required forms mark the optional fields and vice versa.
|
|
72
|
+
*/
|
|
73
|
+
majorityRequired = $derived.by(() => {
|
|
74
|
+
let required = 0;
|
|
75
|
+
let optional = 0;
|
|
76
|
+
for (const field of this.fields.values()) {
|
|
77
|
+
if (field.required)
|
|
78
|
+
required++;
|
|
79
|
+
else
|
|
80
|
+
optional++;
|
|
81
|
+
}
|
|
82
|
+
return required >= optional;
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* Registers a field for dirty tracking, markers and reset. Returns the
|
|
86
|
+
* unregister function — pass it to onDestroy. Field names must be unique
|
|
87
|
+
* within a form; a duplicate name throws.
|
|
88
|
+
*/
|
|
89
|
+
register(field) {
|
|
90
|
+
if (this.fields.has(field.name))
|
|
91
|
+
throw new Error(`FormState: field '${field.name}' is already registered`);
|
|
92
|
+
this.fields.set(field.name, field);
|
|
93
|
+
return () => {
|
|
94
|
+
this.fields.delete(field.name);
|
|
95
|
+
this.touched.delete(field.name);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Marks a field touched so shouldShowIssues() starts returning true for it.
|
|
100
|
+
* The Form calls this AFTER a validation pass settles, so a newly-touched
|
|
101
|
+
* field never flashes issues stale from the previous pass.
|
|
102
|
+
*/
|
|
103
|
+
markTouched(name) {
|
|
104
|
+
this.touched.add(name);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether the named field's issues may be displayed: everything shows once
|
|
108
|
+
* a submit was attempted, before that only touched fields do.
|
|
109
|
+
*/
|
|
110
|
+
shouldShowIssues(name) {
|
|
111
|
+
return this.submitAttempted || this.touched.has(name);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fire-and-forget full validation of the underlying form — preflight-only
|
|
115
|
+
* (off the network) when clientOnlyValidation is set.
|
|
116
|
+
*/
|
|
117
|
+
validate() {
|
|
118
|
+
// Defensive catch: validation for a form that unmounted (or failed a
|
|
119
|
+
// network round-trip) mid-call has nowhere useful to surface from here.
|
|
120
|
+
void Promise.resolve(this.form.validate({
|
|
121
|
+
all: true,
|
|
122
|
+
preflightOnly: this.clientOnlyValidation,
|
|
123
|
+
})).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Restores every registered field to its initial value, clears touched and
|
|
127
|
+
* submit state, and revalidates so stale issues don't outlive the reset.
|
|
128
|
+
*/
|
|
129
|
+
reset() {
|
|
130
|
+
for (const field of this.fields.values()) {
|
|
131
|
+
field.setValue(field.initialValue);
|
|
132
|
+
}
|
|
133
|
+
this.touched.clear();
|
|
134
|
+
this.submitAttempted = false;
|
|
135
|
+
this.submitError = undefined;
|
|
136
|
+
this.validate();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export { FormState };
|
package/dist/form.svelte
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
Wrapper around a SvelteKit remote form: enhances it and provides the context
|
|
3
|
+
every `@privaty/ui-forms` input and button requires. With a `schema`, typing
|
|
4
|
+
validates client-side (preflight); without one, validation is a debounced
|
|
5
|
+
server round-trip. A field's issues stay hidden until it is touched or a
|
|
6
|
+
submit is attempted; submission always validates server-side regardless.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts" generics="Input extends RemoteFormInput, Output">
|
|
9
|
+
import { cn } from "@privaty/ui/cn.js";
|
|
10
|
+
import type { RemoteForm, RemoteFormInput } from "$app/server";
|
|
11
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
12
|
+
import { onDestroy, onMount, type Snippet } from "svelte";
|
|
13
|
+
import { setFormContext } from "./context";
|
|
14
|
+
import { FormState } from "./form-state.svelte";
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
/** The SvelteKit remote form instance to enhance. Captured on first
|
|
18
|
+
* render — it must stay the same instance for the component's lifetime. */
|
|
19
|
+
form: Omit<RemoteForm<Input, Output>, "for">;
|
|
20
|
+
/** Standard Schema for client-side (preflight) validation while typing —
|
|
21
|
+
* without it, every validation pass is a server round-trip.
|
|
22
|
+
* Output deliberately unconstrained: transform schemas (Output ≠ Input)
|
|
23
|
+
* are Kit-legal and only the Input side matters for preflight. */
|
|
24
|
+
schema?: StandardSchemaV1<Input, unknown>;
|
|
25
|
+
|
|
26
|
+
/** Debounce (ms) for validation while typing wherever validation is a
|
|
27
|
+
* server round-trip: always on SCHEMA-LESS forms, and on schema'd forms
|
|
28
|
+
* while server-produced issues are being refreshed. Client-side-only
|
|
29
|
+
* validation stays immediate. Defaults to 400. */
|
|
30
|
+
validationDebounce?: number;
|
|
31
|
+
|
|
32
|
+
/** Reset the form after a successful submission, restoring every field's
|
|
33
|
+
* initial value. Defaults to true. */
|
|
34
|
+
resetOnSuccess?: boolean;
|
|
35
|
+
|
|
36
|
+
/** Called after a successful submission with the remote function's
|
|
37
|
+
* result (after the reset when `resetOnSuccess` is on). */
|
|
38
|
+
onsuccess?: (result: Output | undefined) => void | Promise<void>;
|
|
39
|
+
/** Called when the submission flow throws — the round-trip, the submit
|
|
40
|
+
* itself, or `onsuccess`. The same error is kept as FormState.submitError,
|
|
41
|
+
* which <FormError> renders as a general error message. */
|
|
42
|
+
onerror?: (error: unknown) => void | Promise<void>;
|
|
43
|
+
|
|
44
|
+
/** Extra classes for the <form> element. */
|
|
45
|
+
class?: string;
|
|
46
|
+
|
|
47
|
+
/** Form content — the inputs and buttons that read this form's context. */
|
|
48
|
+
children: Snippet;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
form,
|
|
53
|
+
schema,
|
|
54
|
+
|
|
55
|
+
validationDebounce = 400,
|
|
56
|
+
|
|
57
|
+
resetOnSuccess = true,
|
|
58
|
+
|
|
59
|
+
onsuccess,
|
|
60
|
+
onerror,
|
|
61
|
+
|
|
62
|
+
class: classes,
|
|
63
|
+
|
|
64
|
+
children,
|
|
65
|
+
}: Props = $props();
|
|
66
|
+
|
|
67
|
+
// The remote form instance is stable for the component's lifetime, so
|
|
68
|
+
// capturing the initial prop value is intentional. When a schema is given,
|
|
69
|
+
// the preflighted instance is used for everything so client-side
|
|
70
|
+
// (`preflightOnly`) validation actually has a schema to run.
|
|
71
|
+
// svelte-ignore state_referenced_locally
|
|
72
|
+
const instance = schema ? form.preflight(schema) : form;
|
|
73
|
+
|
|
74
|
+
const state = new FormState(instance);
|
|
75
|
+
// svelte-ignore state_referenced_locally
|
|
76
|
+
state.clientOnlyValidation = schema !== undefined;
|
|
77
|
+
setFormContext({ form: instance, state });
|
|
78
|
+
|
|
79
|
+
// Kit flags issues that came from the SERVER (a rejected submission, a
|
|
80
|
+
// server validation round-trip) and persists them through every client-side
|
|
81
|
+
// validation pass — no edit can refresh them, only another round-trip
|
|
82
|
+
// (merge_with_server_issues, verified against next.25). The flag itself is
|
|
83
|
+
// stripped from the public issues shape, so their presence is tracked here:
|
|
84
|
+
// set on rejection, cleared by a clean full validation, submission or
|
|
85
|
+
// reset. While set, input revalidation escalates to full validation so
|
|
86
|
+
// server-judged rules (a cross-field cap, a uniqueness check) stay live.
|
|
87
|
+
let serverIssuesPresent = false;
|
|
88
|
+
|
|
89
|
+
// A parent mounts after its children, so every field has registered by now.
|
|
90
|
+
onMount(() => {
|
|
91
|
+
state.settled = true;
|
|
92
|
+
|
|
93
|
+
// Issues present before any client-side validation could have run are
|
|
94
|
+
// server-produced — the SSR-restored rejection of a no-JS submission.
|
|
95
|
+
serverIssuesPresent = (instance.fields.allIssues()?.length ?? 0) > 0;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// With a schema, typing validates client-side only — the server isn't
|
|
99
|
+
// involved until submission (which validates server-side regardless).
|
|
100
|
+
function validate() {
|
|
101
|
+
return instance.validate({
|
|
102
|
+
all: true,
|
|
103
|
+
preflightOnly: schema !== undefined,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const attributes = instance.enhance(async (enhanceInstance) => {
|
|
108
|
+
state.submitError = undefined;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Schema-less only: validate BEFORE opening the error gates (issues
|
|
112
|
+
// must be fresh when submitAttempted makes them all visible) and gate
|
|
113
|
+
// the submit on the fresh result. Inside the try: the validation is a
|
|
114
|
+
// server round-trip whose failure must surface as submitError, not
|
|
115
|
+
// escalate to the nearest +error page. Schema'd forms skip this — Kit's
|
|
116
|
+
// preflight already gated client validity before this callback ran
|
|
117
|
+
// (handleSubmit opens their gates), and lingering SERVER issues must
|
|
118
|
+
// never block here: client-side validation cannot refresh them, so
|
|
119
|
+
// gating on isValid would deadlock resubmission on problems the user
|
|
120
|
+
// already fixed. The submission itself re-judges them authoritatively.
|
|
121
|
+
if (schema === undefined) {
|
|
122
|
+
await validate();
|
|
123
|
+
state.submitAttempted = true;
|
|
124
|
+
if (!state.isValid) return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!(await enhanceInstance.submit())) {
|
|
128
|
+
serverIssuesPresent = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
serverIssuesPresent = false;
|
|
132
|
+
|
|
133
|
+
if (resetOnSuccess) enhanceInstance.element.reset();
|
|
134
|
+
|
|
135
|
+
await onsuccess?.(instance.result);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
state.submitError = error;
|
|
138
|
+
await onerror?.(error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// With a schema, Kit's preflight runs BEFORE the enhance callback and
|
|
143
|
+
// silently swallows invalid submits — the callback never gets to flip
|
|
144
|
+
// submitAttempted, so a rejected click would show nothing anywhere. This
|
|
145
|
+
// form-level listener fires regardless of Kit's gate, validates (fresh
|
|
146
|
+
// issues before the gates open — the flash rule), then opens them.
|
|
147
|
+
function handleSubmit() {
|
|
148
|
+
if (schema === undefined) return;
|
|
149
|
+
|
|
150
|
+
void Promise.resolve(validate())
|
|
151
|
+
.catch(() => {})
|
|
152
|
+
.finally(() => {
|
|
153
|
+
state.submitAttempted = true;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
onDestroy(() => clearTimeout(debounceTimer));
|
|
159
|
+
|
|
160
|
+
async function handleInput(event: Event) {
|
|
161
|
+
const name =
|
|
162
|
+
event.target instanceof HTMLElement
|
|
163
|
+
? event.target.getAttribute("name")
|
|
164
|
+
: null;
|
|
165
|
+
|
|
166
|
+
// Schema'd forms validate client-side and immediately — EXCEPT while
|
|
167
|
+
// server issues linger, which client-side validation can never refresh:
|
|
168
|
+
// those passes escalate to full validation (client schema first, then —
|
|
169
|
+
// only once it passes — the server round-trip that replaces the whole
|
|
170
|
+
// issue set), so a server-judged rule updates live as its inputs change.
|
|
171
|
+
if (schema !== undefined && !serverIssuesPresent) {
|
|
172
|
+
try {
|
|
173
|
+
// Same ordering rule: a newly-touched field must never flash issues
|
|
174
|
+
// that are stale from the previous validation pass.
|
|
175
|
+
await validate();
|
|
176
|
+
} catch {
|
|
177
|
+
// The form can unmount mid-validation — nothing left to show on.
|
|
178
|
+
} finally {
|
|
179
|
+
if (name) state.markTouched(name);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Every validation from here is a server round-trip, so typing is
|
|
185
|
+
// debounced. The touch waits for the validation, preserving the
|
|
186
|
+
// no-stale-issues ordering.
|
|
187
|
+
clearTimeout(debounceTimer);
|
|
188
|
+
debounceTimer = setTimeout(() => {
|
|
189
|
+
void Promise.resolve(
|
|
190
|
+
instance.validate({ all: true, preflightOnly: false }),
|
|
191
|
+
)
|
|
192
|
+
.then(() => {
|
|
193
|
+
// An empty result proves the server issues are gone — drop back to
|
|
194
|
+
// the immediate client-only cadence. Non-empty stays escalated: the
|
|
195
|
+
// set may still hold server issues (their flag isn't visible here).
|
|
196
|
+
serverIssuesPresent = (instance.fields.allIssues()?.length ?? 0) > 0;
|
|
197
|
+
})
|
|
198
|
+
.catch(() => {})
|
|
199
|
+
.finally(() => {
|
|
200
|
+
if (name) state.markTouched(name);
|
|
201
|
+
});
|
|
202
|
+
}, validationDebounce);
|
|
203
|
+
}
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<form
|
|
207
|
+
{...attributes}
|
|
208
|
+
class={cn("flex flex-col gap-3", classes)}
|
|
209
|
+
oninput={handleInput}
|
|
210
|
+
onsubmit={handleSubmit}
|
|
211
|
+
onreset={() => {
|
|
212
|
+
// Kit's own reset handler clears the whole issue set, server issues
|
|
213
|
+
// included.
|
|
214
|
+
serverIssuesPresent = false;
|
|
215
|
+
state.reset();
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{@render children()}
|
|
219
|
+
</form>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { RemoteForm, RemoteFormInput } from "$app/server";
|
|
2
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
import { type Snippet } from "svelte";
|
|
4
|
+
declare function $$render<Input extends RemoteFormInput, Output>(): {
|
|
5
|
+
props: {
|
|
6
|
+
/** The SvelteKit remote form instance to enhance. Captured on first
|
|
7
|
+
* render — it must stay the same instance for the component's lifetime. */
|
|
8
|
+
form: Omit<RemoteForm<Input, Output>, "for">;
|
|
9
|
+
/** Standard Schema for client-side (preflight) validation while typing —
|
|
10
|
+
* without it, every validation pass is a server round-trip.
|
|
11
|
+
* Output deliberately unconstrained: transform schemas (Output ≠ Input)
|
|
12
|
+
* are Kit-legal and only the Input side matters for preflight. */
|
|
13
|
+
schema?: StandardSchemaV1<Input, unknown>;
|
|
14
|
+
/** Debounce (ms) for validation while typing wherever validation is a
|
|
15
|
+
* server round-trip: always on SCHEMA-LESS forms, and on schema'd forms
|
|
16
|
+
* while server-produced issues are being refreshed. Client-side-only
|
|
17
|
+
* validation stays immediate. Defaults to 400. */
|
|
18
|
+
validationDebounce?: number;
|
|
19
|
+
/** Reset the form after a successful submission, restoring every field's
|
|
20
|
+
* initial value. Defaults to true. */
|
|
21
|
+
resetOnSuccess?: boolean;
|
|
22
|
+
/** Called after a successful submission with the remote function's
|
|
23
|
+
* result (after the reset when `resetOnSuccess` is on). */
|
|
24
|
+
onsuccess?: (result: Output | undefined) => void | Promise<void>;
|
|
25
|
+
/** Called when the submission flow throws — the round-trip, the submit
|
|
26
|
+
* itself, or `onsuccess`. The same error is kept as FormState.submitError,
|
|
27
|
+
* which <FormError> renders as a general error message. */
|
|
28
|
+
onerror?: (error: unknown) => void | Promise<void>;
|
|
29
|
+
/** Extra classes for the <form> element. */
|
|
30
|
+
class?: string;
|
|
31
|
+
/** Form content — the inputs and buttons that read this form's context. */
|
|
32
|
+
children: Snippet;
|
|
33
|
+
};
|
|
34
|
+
exports: {};
|
|
35
|
+
bindings: "";
|
|
36
|
+
slots: {};
|
|
37
|
+
events: {};
|
|
38
|
+
};
|
|
39
|
+
declare class __sveltets_Render<Input extends RemoteFormInput, Output> {
|
|
40
|
+
props(): ReturnType<typeof $$render<Input, Output>>['props'];
|
|
41
|
+
events(): ReturnType<typeof $$render<Input, Output>>['events'];
|
|
42
|
+
slots(): ReturnType<typeof $$render<Input, Output>>['slots'];
|
|
43
|
+
bindings(): "";
|
|
44
|
+
exports(): {};
|
|
45
|
+
}
|
|
46
|
+
interface $$IsomorphicComponent {
|
|
47
|
+
new <Input extends RemoteFormInput, Output>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Input, Output>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Input, Output>['props']>, ReturnType<__sveltets_Render<Input, Output>['events']>, ReturnType<__sveltets_Render<Input, Output>['slots']>> & {
|
|
48
|
+
$$bindings?: ReturnType<__sveltets_Render<Input, Output>['bindings']>;
|
|
49
|
+
} & ReturnType<__sveltets_Render<Input, Output>['exports']>;
|
|
50
|
+
<Input extends RemoteFormInput, Output>(internal: unknown, props: ReturnType<__sveltets_Render<Input, Output>['props']> & {}): ReturnType<__sveltets_Render<Input, Output>['exports']>;
|
|
51
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Wrapper around a SvelteKit remote form: enhances it and provides the context
|
|
55
|
+
* every `@privaty/ui-forms` input and button requires. With a `schema`, typing
|
|
56
|
+
* validates client-side (preflight); without one, validation is a debounced
|
|
57
|
+
* server round-trip. A field's issues stay hidden until it is touched or a
|
|
58
|
+
* submit is attempted; submission always validates server-side regardless.
|
|
59
|
+
*/
|
|
60
|
+
declare const Form: $$IsomorphicComponent;
|
|
61
|
+
type Form<Input extends RemoteFormInput, Output> = InstanceType<typeof Form<Input, Output>>;
|
|
62
|
+
export default Form;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
Checkbox wired to a SvelteKit remote form boolean field — must render inside
|
|
3
|
+
a <Form>, whose context it registers with. Shows resolver-translated
|
|
4
|
+
validation errors once the form state reveals them, plus a majority-aware
|
|
5
|
+
required/optional marker. While submitting it locks interaction with CSS and
|
|
6
|
+
key swallowing instead of disabling — a disabled checkbox is excluded from
|
|
7
|
+
FormData and would silently submit as false.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { cn } from "@privaty/ui/cn.js";
|
|
11
|
+
import Checkbox from "@privaty/ui/components/checkbox.svelte";
|
|
12
|
+
import type { CheckboxField } from "../types/field";
|
|
13
|
+
import { wireField } from "./wire-field";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** The SvelteKit remote form field to bind. Structural — anything
|
|
17
|
+
* satisfying the CheckboxField slice works, e.g. a fake from the public
|
|
18
|
+
* testing subpath. */
|
|
19
|
+
field: CheckboxField;
|
|
20
|
+
/** Visible label text, rendered next to the box. */
|
|
21
|
+
label: string;
|
|
22
|
+
|
|
23
|
+
/** Marks the field required: feeds the majority-aware required/optional
|
|
24
|
+
* marker (required markers styled red) — forms where at least half the
|
|
25
|
+
* fields are required mark the optional ones instead. Validation itself
|
|
26
|
+
* comes from the field's schema, not this flag, and it is not forwarded
|
|
27
|
+
* as a native `required` attribute. */
|
|
28
|
+
required?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Checkboxes have no native readonly, so submitting locks interaction
|
|
31
|
+
* with CSS instead. NEVER disable while submitting: disabled controls are
|
|
32
|
+
* excluded from FormData — a checked box would silently submit as false.
|
|
33
|
+
*/
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
|
|
36
|
+
/** Initial checked state (default false); native form reset restores
|
|
37
|
+
* it. */
|
|
38
|
+
initialValue?: boolean;
|
|
39
|
+
|
|
40
|
+
/** Extra classes for the outer field wrapper. */
|
|
41
|
+
class?: string;
|
|
42
|
+
/** Extra classes for the <label> element. */
|
|
43
|
+
labelClass?: string;
|
|
44
|
+
/** Extra classes for the <input> element. */
|
|
45
|
+
inputClass?: string;
|
|
46
|
+
/** Extra classes for the required/optional marker. */
|
|
47
|
+
markerClass?: string;
|
|
48
|
+
/** Extra classes for the error <ul>. */
|
|
49
|
+
errorClass?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
field,
|
|
54
|
+
label,
|
|
55
|
+
|
|
56
|
+
required = false,
|
|
57
|
+
disabled = false,
|
|
58
|
+
|
|
59
|
+
initialValue = false,
|
|
60
|
+
|
|
61
|
+
class: classes,
|
|
62
|
+
labelClass,
|
|
63
|
+
inputClass,
|
|
64
|
+
markerClass,
|
|
65
|
+
errorClass,
|
|
66
|
+
}: Props = $props();
|
|
67
|
+
|
|
68
|
+
// The seed rides Kit's as(): it supplies both `checked` and the
|
|
69
|
+
// `defaultChecked` getter (native reset restores the seed). Kit's
|
|
70
|
+
// defaultChecked is non-configurable — never add our own on top.
|
|
71
|
+
const attributes = $derived(field.as("checkbox", initialValue));
|
|
72
|
+
|
|
73
|
+
// The field and initialValue are stable for the component's lifetime, so
|
|
74
|
+
// capturing the initial name and registration is intentional.
|
|
75
|
+
// svelte-ignore state_referenced_locally
|
|
76
|
+
const name = attributes.name;
|
|
77
|
+
|
|
78
|
+
// svelte-ignore state_referenced_locally
|
|
79
|
+
const wired = wireField({
|
|
80
|
+
name,
|
|
81
|
+
initialValue,
|
|
82
|
+
required,
|
|
83
|
+
issues: () => field.issues(),
|
|
84
|
+
// Kit's field state: undefined = untouched (fall back to the seed);
|
|
85
|
+
// null = explicitly UNCHECKED (must not fall through to the seed).
|
|
86
|
+
getValue: () => {
|
|
87
|
+
const value = field.value();
|
|
88
|
+
return value === undefined ? initialValue : value;
|
|
89
|
+
},
|
|
90
|
+
setValue: (value) => field.set(value as boolean),
|
|
91
|
+
// Mid-edit Kit stores the raw DOM value: "on" when checked, null when
|
|
92
|
+
// unchecked.
|
|
93
|
+
normalize: (value) => value === true || value === "on",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// pointer-events-none blocks the mouse while submitting but not the
|
|
97
|
+
// keyboard — swallow value-changing keys too (Tab stays free).
|
|
98
|
+
function lockKeysWhileSubmitting(event: KeyboardEvent) {
|
|
99
|
+
if (!wired.state.isSubmitting) return;
|
|
100
|
+
if ([" ", "Enter"].includes(event.key)) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<Checkbox
|
|
107
|
+
{...attributes}
|
|
108
|
+
{label}
|
|
109
|
+
errors={wired.errors}
|
|
110
|
+
marker={wired.marker}
|
|
111
|
+
aria-invalid={wired.errors.length > 0 ? true : undefined}
|
|
112
|
+
onkeydown={lockKeysWhileSubmitting}
|
|
113
|
+
{disabled}
|
|
114
|
+
class={classes}
|
|
115
|
+
{labelClass}
|
|
116
|
+
inputClass={cn(wired.state.isSubmitting && "pointer-events-none", inputClass)}
|
|
117
|
+
markerClass={cn(required && "text-red-700 dark:text-red-500", markerClass)}
|
|
118
|
+
{errorClass}
|
|
119
|
+
/>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CheckboxField } from "../types/field";
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The SvelteKit remote form field to bind. Structural — anything
|
|
4
|
+
* satisfying the CheckboxField slice works, e.g. a fake from the public
|
|
5
|
+
* testing subpath. */
|
|
6
|
+
field: CheckboxField;
|
|
7
|
+
/** Visible label text, rendered next to the box. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Marks the field required: feeds the majority-aware required/optional
|
|
10
|
+
* marker (required markers styled red) — forms where at least half the
|
|
11
|
+
* fields are required mark the optional ones instead. Validation itself
|
|
12
|
+
* comes from the field's schema, not this flag, and it is not forwarded
|
|
13
|
+
* as a native `required` attribute. */
|
|
14
|
+
required?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Checkboxes have no native readonly, so submitting locks interaction
|
|
17
|
+
* with CSS instead. NEVER disable while submitting: disabled controls are
|
|
18
|
+
* excluded from FormData — a checked box would silently submit as false.
|
|
19
|
+
*/
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** Initial checked state (default false); native form reset restores
|
|
22
|
+
* it. */
|
|
23
|
+
initialValue?: boolean;
|
|
24
|
+
/** Extra classes for the outer field wrapper. */
|
|
25
|
+
class?: string;
|
|
26
|
+
/** Extra classes for the <label> element. */
|
|
27
|
+
labelClass?: string;
|
|
28
|
+
/** Extra classes for the <input> element. */
|
|
29
|
+
inputClass?: string;
|
|
30
|
+
/** Extra classes for the required/optional marker. */
|
|
31
|
+
markerClass?: string;
|
|
32
|
+
/** Extra classes for the error <ul>. */
|
|
33
|
+
errorClass?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Checkbox wired to a SvelteKit remote form boolean field — must render inside
|
|
37
|
+
* a <Form>, whose context it registers with. Shows resolver-translated
|
|
38
|
+
* validation errors once the form state reveals them, plus a majority-aware
|
|
39
|
+
* required/optional marker. While submitting it locks interaction with CSS and
|
|
40
|
+
* key swallowing instead of disabling — a disabled checkbox is excluded from
|
|
41
|
+
* FormData and would silently submit as false.
|
|
42
|
+
*/
|
|
43
|
+
declare const CheckboxInput: import("svelte").Component<Props, {}, "">;
|
|
44
|
+
type CheckboxInput = ReturnType<typeof CheckboxInput>;
|
|
45
|
+
export default CheckboxInput;
|