sv5ui 1.5.1 → 1.6.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.
@@ -9,7 +9,7 @@
9
9
  import { checkboxVariants, checkboxDefaults } from './checkbox.variants.js'
10
10
  import { getComponentConfig, iconsDefaults } from '../config.js'
11
11
  import Icon from '../Icon/Icon.svelte'
12
- import { useFormField } from '../hooks/useFormField.svelte.js'
12
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
13
13
 
14
14
  const config = getComponentConfig('checkbox', checkboxDefaults)
15
15
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -43,6 +43,7 @@
43
43
  }: Props = $props()
44
44
 
45
45
  const formFieldContext = useFormField()
46
+ const emit = useFormFieldEmit()
46
47
 
47
48
  const hasError = $derived(
48
49
  formFieldContext?.error !== undefined && formFieldContext?.error !== false
@@ -107,9 +108,14 @@
107
108
  <div bind:this={containerRef} class={classes.container}>
108
109
  <Checkbox.Root
109
110
  bind:checked
110
- {onCheckedChange}
111
+ onCheckedChange={(val) => {
112
+ emit.onChange()
113
+ onCheckedChange?.(val)
114
+ }}
111
115
  bind:indeterminate
112
116
  {onIndeterminateChange}
117
+ onblur={() => emit.onBlur()}
118
+ onfocus={() => emit.onFocus()}
113
119
  id={resolvedId}
114
120
  name={resolvedName}
115
121
  {value}
@@ -9,7 +9,7 @@
9
9
  import { checkboxGroupVariants, checkboxGroupDefaults } from './checkbox-group.variants.js'
10
10
  import { getComponentConfig, iconsDefaults } from '../config.js'
11
11
  import Icon from '../Icon/Icon.svelte'
12
- import { useFormField } from '../hooks/useFormField.svelte.js'
12
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
13
13
  import type { CheckboxGroupItem } from './checkbox-group.types.js'
14
14
 
15
15
  const config = getComponentConfig('checkboxGroup', checkboxGroupDefaults)
@@ -42,6 +42,7 @@
42
42
  }: Props = $props()
43
43
 
44
44
  const formFieldContext = useFormField()
45
+ const emit = useFormFieldEmit()
45
46
 
46
47
  const hasError = $derived(
47
48
  formFieldContext?.error !== undefined && formFieldContext?.error !== false
@@ -97,6 +98,7 @@
97
98
  } else {
98
99
  value = value.filter((v) => v !== itemValue)
99
100
  }
101
+ emit.onChange()
100
102
  onValueChange?.(value)
101
103
  }
102
104
 
@@ -169,7 +171,18 @@
169
171
  {/snippet}
170
172
 
171
173
  <div {...restProps} bind:this={ref} class={layoutClasses.root}>
172
- <fieldset class={layoutClasses.fieldset} aria-describedby={ariaDescribedBy}>
174
+ <fieldset
175
+ class={layoutClasses.fieldset}
176
+ aria-describedby={ariaDescribedBy}
177
+ onfocusin={() => emit.onFocus()}
178
+ onfocusout={(e) => {
179
+ // Only emit blur when focus leaves the fieldset entirely, not when
180
+ // moving between checkboxes within the group.
181
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
182
+ emit.onBlur()
183
+ }
184
+ }}
185
+ >
173
186
  {#if legend || legendSlot}
174
187
  {#if legendSlot}
175
188
  {@render legendSlot({ legend })}
@@ -0,0 +1,203 @@
1
+ <script lang="ts" module>
2
+ import type { FormProps, FormSchema } from './form.types.js'
3
+
4
+ export type Props<
5
+ S extends FormSchema | undefined = FormSchema | undefined,
6
+ T extends boolean = true,
7
+ N extends boolean = false
8
+ > = FormProps<S, T, N>
9
+ </script>
10
+
11
+ <script
12
+ lang="ts"
13
+ generics="S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false"
14
+ >
15
+ import { setContext, onMount, onDestroy, untrack } from 'svelte'
16
+ import type { HTMLAttributes } from 'svelte/elements'
17
+ import { formVariants, formDefaults } from './form.variants.js'
18
+ import { FormContext, FORM_CONTEXT_KEY, getFormContext } from './form.context.svelte.js'
19
+ import { getComponentConfig } from '../config.js'
20
+ import type { FormApi, FormErrorWithId } from './form.types.js'
21
+
22
+ const config = getComponentConfig('form', formDefaults)
23
+
24
+ let {
25
+ ref = $bindable(null),
26
+ api = $bindable<FormApi<unknown> | undefined>(),
27
+ id,
28
+ schema,
29
+ state = $bindable({} as never),
30
+ validate: customValidate,
31
+ validateOn = ['input', 'blur', 'change'],
32
+ validateOnInputDelay = 300,
33
+ disabled = false,
34
+ loadingAuto = true,
35
+ transform = true as T,
36
+ nested = false as N,
37
+ name,
38
+ onsubmit,
39
+ onerror,
40
+ class: className,
41
+ ui,
42
+ children,
43
+ ...restProps
44
+ }: Props<S, T, N> = $props()
45
+
46
+ // Generate a stable form id. Both `id` and `nested` are effectively init-only
47
+ // (they should not change over a form instance's lifetime), so `untrack`
48
+ // silences Svelte's state_referenced_locally warning.
49
+ const formId: string | number = untrack(
50
+ () =>
51
+ (id as string | number | undefined) ??
52
+ (typeof crypto !== 'undefined' && 'randomUUID' in crypto
53
+ ? crypto.randomUUID()
54
+ : `sv5ui-form-${Math.random().toString(36).slice(2)}`)
55
+ )
56
+
57
+ // Parent context captured once at init — `nested` is effectively immutable
58
+ // over a form instance's lifetime. We warn (dev-only) if it's mutated later.
59
+ const parentCtx = untrack(() => (nested ? getFormContext() : undefined))
60
+ const initialNested = untrack(() => nested)
61
+ $effect(() => {
62
+ if (nested !== initialNested) {
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ '[sv5ui Form] The `nested` prop was changed after mount. This is ' +
66
+ 'not supported — nested/standalone status is fixed at mount time. ' +
67
+ 'Unmount and remount the Form to toggle.'
68
+ )
69
+ }
70
+ })
71
+
72
+ // Create the reactive form context. All config is passed via getter closures
73
+ // so that reactive prop changes are visible inside the class.
74
+ const ctx = new FormContext(
75
+ {
76
+ getState: () => state as unknown,
77
+ getSchema: () => schema as FormSchema | undefined,
78
+ getCustomValidate: () =>
79
+ customValidate as
80
+ | ((s: unknown) => ReturnType<NonNullable<typeof customValidate>>)
81
+ | undefined,
82
+ getValidateOn: () => validateOn,
83
+ getValidateOnInputDelay: () => validateOnInputDelay,
84
+ getDisabled: () => disabled,
85
+ getLoadingAuto: () => loadingAuto,
86
+ getTransform: () => transform,
87
+ getName: () => name as string | undefined,
88
+ getOnSubmit: () =>
89
+ onsubmit as
90
+ | ((
91
+ e: import('./form.types.js').FormSubmitEvent<unknown>
92
+ ) => void | Promise<void>)
93
+ | undefined,
94
+ getOnError: () => onerror
95
+ },
96
+ formId,
97
+ parentCtx
98
+ )
99
+
100
+ setContext(FORM_CONTEXT_KEY, ctx)
101
+
102
+ // Build the bindable API object. Getters ensure reactive state reads stay live.
103
+ const apiObject: FormApi<unknown> = {
104
+ validate: (opts) => ctx.validate(opts) as Promise<unknown | false>,
105
+ submit: () => ctx.submit(),
106
+ clear: (name) => ctx.clear(name),
107
+ getErrors: (name) => ctx.getErrors(name),
108
+ setErrors: (errs, name) => ctx.setErrors(errs, name),
109
+ reset: () => ctx.reset(),
110
+ get errors() {
111
+ return ctx.errors
112
+ },
113
+ get loading() {
114
+ return ctx.loading
115
+ },
116
+ get disabled() {
117
+ return ctx.disabled
118
+ },
119
+ get dirty() {
120
+ return ctx.dirty
121
+ },
122
+ get dirtyFields() {
123
+ return ctx.dirtyFields as ReadonlySet<string>
124
+ },
125
+ get touchedFields() {
126
+ return ctx.touchedFields as ReadonlySet<string>
127
+ },
128
+ get blurredFields() {
129
+ return ctx.blurredFields as ReadonlySet<string>
130
+ },
131
+ get submitCount() {
132
+ return ctx.submitCount
133
+ }
134
+ }
135
+
136
+ // Sync the api object to the bindable prop once at setup time. `apiObject`
137
+ // is a stable reference built from getters, so there's nothing reactive to
138
+ // track — direct assignment is sufficient.
139
+ api = apiObject as typeof api
140
+
141
+ // Nested form attach/detach lifecycle.
142
+ onMount(() => {
143
+ if (parentCtx && nested) {
144
+ parentCtx.attachChild(formId, {
145
+ formId,
146
+ name: name as string | undefined,
147
+ validate: (opts) => ctx.validate(opts) as Promise<unknown | false>,
148
+ clear: (n) => ctx.clear(n),
149
+ reset: () => ctx.reset(),
150
+ setErrors: (errs, n) => ctx.setErrors(errs, n)
151
+ })
152
+ }
153
+ })
154
+
155
+ onDestroy(() => {
156
+ if (parentCtx && nested) parentCtx.detachChild(formId)
157
+ ctx.dispose()
158
+ })
159
+
160
+ async function handleSubmit(event: SubmitEvent) {
161
+ event.preventDefault()
162
+ // Forward the real SubmitEvent so user handlers can read `submitter`,
163
+ // `target`, etc. When `api.submit()` is called programmatically, ctx.submit
164
+ // synthesizes a fresh event instead.
165
+ await ctx.submit(event)
166
+ }
167
+
168
+ // `formVariants` has no variants (only a single `root` slot), so its
169
+ // output is stable — compute once at setup rather than via $derived.
170
+ const variantSlots = formVariants()
171
+ const classes = $derived({
172
+ root: variantSlots.root({
173
+ class: [config.slots.root, className, ui?.root]
174
+ })
175
+ })
176
+
177
+ // Slot props for children
178
+ const slotProps = $derived({
179
+ errors: ctx.errors as FormErrorWithId[],
180
+ loading: ctx.loading
181
+ })
182
+ </script>
183
+
184
+ {#if nested}
185
+ <div
186
+ {...restProps as unknown as HTMLAttributes<HTMLDivElement>}
187
+ bind:this={ref}
188
+ id={typeof id === 'number' ? String(id) : id}
189
+ class={classes.root}
190
+ >
191
+ {@render children?.(slotProps)}
192
+ </div>
193
+ {:else}
194
+ <form
195
+ {...restProps}
196
+ bind:this={ref}
197
+ id={typeof id === 'number' ? String(id) : id}
198
+ class={classes.root}
199
+ onsubmit={handleSubmit}
200
+ >
201
+ {@render children?.(slotProps)}
202
+ </form>
203
+ {/if}
@@ -0,0 +1,26 @@
1
+ import type { FormProps, FormSchema } from './form.types.js';
2
+ export type Props<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> = FormProps<S, T, N>;
3
+ declare function $$render<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(): {
4
+ props: Props<S, T, N>;
5
+ exports: {};
6
+ bindings: "ref" | "api" | "state";
7
+ slots: {};
8
+ events: {};
9
+ };
10
+ declare class __sveltets_Render<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> {
11
+ props(): ReturnType<typeof $$render<S, T, N>>['props'];
12
+ events(): ReturnType<typeof $$render<S, T, N>>['events'];
13
+ slots(): ReturnType<typeof $$render<S, T, N>>['slots'];
14
+ bindings(): "ref" | "api" | "state";
15
+ exports(): {};
16
+ }
17
+ interface $$IsomorphicComponent {
18
+ new <S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<S, T, N>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<S, T, N>['props']>, ReturnType<__sveltets_Render<S, T, N>['events']>, ReturnType<__sveltets_Render<S, T, N>['slots']>> & {
19
+ $$bindings?: ReturnType<__sveltets_Render<S, T, N>['bindings']>;
20
+ } & ReturnType<__sveltets_Render<S, T, N>['exports']>;
21
+ <S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<S, T, N>['props']> & {}): ReturnType<__sveltets_Render<S, T, N>['exports']>;
22
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any, any>['bindings']>;
23
+ }
24
+ declare const Form: $$IsomorphicComponent;
25
+ type Form<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> = InstanceType<typeof Form<S, T, N>>;
26
+ export default Form;
@@ -0,0 +1,64 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ import { type FormError, type FormErrorWithId, type FormErrorEvent, type FormFieldRegistryEntry, type FormInputEvents, type FormSchema, type FormSubmitEvent, type FormValidateOpts, type NestedFormEntry } from './form.types.js';
3
+ export declare const FORM_CONTEXT_KEY: unique symbol;
4
+ export declare function getFormContext<T = unknown>(): FormContext<T> | undefined;
5
+ export interface FormContextOptions<T> {
6
+ getState: () => T;
7
+ getSchema: () => FormSchema | undefined;
8
+ getCustomValidate: () => ((state: T) => FormError[] | Promise<FormError[]>) | undefined;
9
+ getValidateOn: () => FormInputEvents[];
10
+ getValidateOnInputDelay: () => number;
11
+ getDisabled: () => boolean;
12
+ getLoadingAuto: () => boolean;
13
+ getTransform: () => boolean;
14
+ getName: () => string | undefined;
15
+ getOnSubmit: () => ((event: FormSubmitEvent<unknown>) => void | Promise<void>) | undefined;
16
+ getOnError: () => ((event: FormErrorEvent) => void) | undefined;
17
+ }
18
+ export declare class FormContext<T = unknown> {
19
+ #private;
20
+ errors: FormErrorWithId[];
21
+ loading: boolean;
22
+ submitCount: number;
23
+ dirtyFields: SvelteSet<string>;
24
+ touchedFields: SvelteSet<string>;
25
+ blurredFields: SvelteSet<string>;
26
+ formId: string | number;
27
+ constructor(opts: FormContextOptions<T>, formId: string | number, parentCtx?: FormContext);
28
+ get dirty(): boolean;
29
+ get disabled(): boolean;
30
+ /**
31
+ * Current state. For nested forms, read the sub-tree from parent state by name.
32
+ * Falls back to `{}` if the parent hasn't initialized the sub-object yet —
33
+ * avoids crashing downstream validators when consumers forget to prefill it.
34
+ */
35
+ get state(): T;
36
+ getErrors(name?: string | RegExp): FormErrorWithId[];
37
+ setErrors(errs: FormError[], name?: string | RegExp): void;
38
+ clear(name?: string | RegExp): void;
39
+ /**
40
+ * Reset all form tracking state: errors, dirty/touched/blurred field sets,
41
+ * and submitCount. Does NOT modify `state` (caller owns that). Cascades to
42
+ * nested forms.
43
+ */
44
+ reset(): void;
45
+ registerField(name: string, entry: FormFieldRegistryEntry): void;
46
+ unregisterField(name: string): void;
47
+ attachChild(childFormId: string | number, entry: NestedFormEntry): void;
48
+ detachChild(childFormId: string | number): void;
49
+ onFocus(name: string): void;
50
+ onBlur(name: string): void;
51
+ onChange(name: string): void;
52
+ onInput(name: string, eager?: boolean): void;
53
+ validate(opts?: FormValidateOpts): Promise<T | false>;
54
+ /**
55
+ * Run full validation and fire onsubmit / onerror.
56
+ *
57
+ * @param sourceEvent - Real SubmitEvent from the `<form>` submit handler,
58
+ * forwarded to the user's onsubmit / onerror so they can read `submitter`,
59
+ * `preventDefault`, etc. When called programmatically (via the public API),
60
+ * a fresh SubmitEvent is synthesized.
61
+ */
62
+ submit(sourceEvent?: SubmitEvent): Promise<void>;
63
+ dispose(): void;
64
+ }