@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.
Files changed (36) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +107 -0
  4. package/dist/components/form-error.svelte +49 -0
  5. package/dist/components/form-error.svelte.d.ts +15 -0
  6. package/dist/components/reset.svelte +49 -0
  7. package/dist/components/reset.svelte.d.ts +19 -0
  8. package/dist/components/submit.svelte +82 -0
  9. package/dist/components/submit.svelte.d.ts +27 -0
  10. package/dist/context.d.ts +23 -0
  11. package/dist/context.js +21 -0
  12. package/dist/form-state.svelte.d.ts +89 -0
  13. package/dist/form-state.svelte.js +139 -0
  14. package/dist/form.svelte +219 -0
  15. package/dist/form.svelte.d.ts +62 -0
  16. package/dist/inputs/checkbox-input.svelte +119 -0
  17. package/dist/inputs/checkbox-input.svelte.d.ts +45 -0
  18. package/dist/inputs/date-input.svelte +132 -0
  19. package/dist/inputs/date-input.svelte.d.ts +61 -0
  20. package/dist/inputs/number-input.svelte +141 -0
  21. package/dist/inputs/number-input.svelte.d.ts +63 -0
  22. package/dist/inputs/select-input.svelte +156 -0
  23. package/dist/inputs/select-input.svelte.d.ts +57 -0
  24. package/dist/inputs/text-input.svelte +126 -0
  25. package/dist/inputs/text-input.svelte.d.ts +59 -0
  26. package/dist/inputs/textarea-input.svelte +135 -0
  27. package/dist/inputs/textarea-input.svelte.d.ts +57 -0
  28. package/dist/inputs/wire-field.d.ts +44 -0
  29. package/dist/inputs/wire-field.js +45 -0
  30. package/dist/testing/fakes.svelte.d.ts +205 -0
  31. package/dist/testing/fakes.svelte.js +346 -0
  32. package/dist/types/field.d.ts +156 -0
  33. package/dist/types/field.js +1 -0
  34. package/dist/types/form.d.ts +27 -0
  35. package/dist/types/form.js +1 -0
  36. 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 };
@@ -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;