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.
@@ -0,0 +1,17 @@
1
+ import { tv } from 'tailwind-variants';
2
+ /**
3
+ * Form component variants.
4
+ *
5
+ * The Form component renders a near-styleless `<form>` (or `<div>` for nested forms) —
6
+ * matching Nuxt UI v4's minimal form theme. This exists primarily so users can inject
7
+ * shared form spacing via `defineConfig({ form: { slots: { root: 'space-y-4' } } })`.
8
+ */
9
+ export const formVariants = tv({
10
+ slots: {
11
+ root: ''
12
+ }
13
+ });
14
+ export const formDefaults = {
15
+ defaultVariants: {},
16
+ slots: {}
17
+ };
@@ -0,0 +1,4 @@
1
+ export { default as Form } from './Form.svelte';
2
+ export type { FormProps, FormApi, FormError, FormErrorWithId, FormSubmitEvent, FormErrorEvent, FormSchema, FormInputEvents, FormValidateOpts, InferInput, InferOutput, FormData, StandardSchemaV1, JoiSchema } from './form.types.js';
3
+ export { FormValidationException } from './form.types.js';
4
+ export { getFormContext } from './form.context.svelte.js';
@@ -0,0 +1,6 @@
1
+ export { default as Form } from './Form.svelte';
2
+ export { FormValidationException } from './form.types.js';
3
+ // Exposed so users can build custom input components that integrate with a parent Form
4
+ // (read errors, emit blur/input/change/focus events). Internal implementation details
5
+ // like FormContext, validateSchema, getAtPath, etc. are intentionally not re-exported.
6
+ export { getFormContext } from './form.context.svelte.js';
@@ -0,0 +1,13 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { FormError, FormSchema, JoiSchema } from './form.types.js';
3
+ export declare function isStandardSchema(schema: unknown): schema is StandardSchemaV1;
4
+ export declare function isJoiSchema(schema: unknown): schema is JoiSchema;
5
+ export interface ValidateResult<T = unknown> {
6
+ errors: FormError[] | null;
7
+ value: T | null;
8
+ }
9
+ export declare function validateStandardSchema<T>(state: unknown, schema: StandardSchemaV1<unknown, T>): Promise<ValidateResult<T>>;
10
+ export declare function validateJoiSchema<T>(state: unknown, schema: JoiSchema): Promise<ValidateResult<T>>;
11
+ export declare function validateSchema<T>(state: unknown, schema: FormSchema): Promise<ValidateResult<T>>;
12
+ export declare function getAtPath<T extends Record<string, unknown>>(data: T, path?: string): unknown;
13
+ export declare function setAtPath<T extends Record<string, unknown>>(data: T, path: string, value: unknown): T;
@@ -0,0 +1,113 @@
1
+ // ==================== TYPE GUARDS ====================
2
+ export function isStandardSchema(schema) {
3
+ return (typeof schema === 'object' &&
4
+ schema !== null &&
5
+ '~standard' in schema &&
6
+ typeof schema['~standard'] === 'object');
7
+ }
8
+ export function isJoiSchema(schema) {
9
+ if (typeof schema !== 'object' || schema === null)
10
+ return false;
11
+ const s = schema;
12
+ return (typeof s.validate === 'function' &&
13
+ typeof s.validateAsync === 'function' &&
14
+ '$_root' in s &&
15
+ typeof s.type === 'string');
16
+ }
17
+ // ==================== NORMALIZE STANDARD SCHEMA PATH ====================
18
+ function normalizePath(path) {
19
+ if (!path || path.length === 0)
20
+ return '';
21
+ return path
22
+ .map((segment) => {
23
+ if (typeof segment === 'object' && segment !== null && 'key' in segment) {
24
+ return String(segment.key);
25
+ }
26
+ return String(segment);
27
+ })
28
+ .join('.');
29
+ }
30
+ // ==================== STANDARD SCHEMA VALIDATOR ====================
31
+ // Handles Zod 3.24+, Valibot 1.0+, Yup 1.7+ (and any other library
32
+ // implementing the Standard Schema spec).
33
+ export async function validateStandardSchema(state, schema) {
34
+ let result = schema['~standard'].validate(state);
35
+ if (result instanceof Promise)
36
+ result = await result;
37
+ if ('issues' in result && result.issues) {
38
+ return {
39
+ errors: result.issues.map((issue) => ({
40
+ name: normalizePath(issue.path),
41
+ message: issue.message
42
+ })),
43
+ value: null
44
+ };
45
+ }
46
+ return {
47
+ errors: null,
48
+ value: result.value
49
+ };
50
+ }
51
+ // ==================== JOI VALIDATOR ====================
52
+ export async function validateJoiSchema(state, schema) {
53
+ // abortEarly: false → collect all errors, not just the first.
54
+ const result = schema.validate(state, { abortEarly: false });
55
+ if (result.error) {
56
+ return {
57
+ errors: result.error.details.map((detail) => ({
58
+ name: detail.path.map(String).join('.'),
59
+ message: detail.message
60
+ })),
61
+ value: null
62
+ };
63
+ }
64
+ return {
65
+ errors: null,
66
+ value: (result.value ?? null)
67
+ };
68
+ }
69
+ // ==================== DISPATCH ====================
70
+ export function validateSchema(state, schema) {
71
+ // Joi is checked first, even though Joi 18+ also implements Standard Schema:
72
+ // Joi's Standard Schema impl defaults to `abortEarly: true` (stopping at the
73
+ // first error), whereas we want to surface ALL errors per field so the user
74
+ // can see every invalid field at once. The dedicated Joi adapter passes
75
+ // `{ abortEarly: false }` explicitly.
76
+ if (isJoiSchema(schema)) {
77
+ return validateJoiSchema(state, schema);
78
+ }
79
+ if (isStandardSchema(schema)) {
80
+ return validateStandardSchema(state, schema);
81
+ }
82
+ throw new Error('Form validation failed: Unsupported form schema');
83
+ }
84
+ // ==================== PATH UTILITIES (nested form support) ====================
85
+ export function getAtPath(data, path) {
86
+ if (!path)
87
+ return data;
88
+ return path.split('.').reduce((value, key) => {
89
+ if (value === null || value === undefined)
90
+ return undefined;
91
+ return value[key];
92
+ }, data);
93
+ }
94
+ export function setAtPath(data, path, value) {
95
+ if (!path) {
96
+ return Object.assign(data, value);
97
+ }
98
+ if (!data)
99
+ return data;
100
+ const keys = path.split('.');
101
+ let current = data;
102
+ for (let i = 0; i < keys.length - 1; i++) {
103
+ const key = keys[i];
104
+ if (current[key] === undefined || current[key] === null) {
105
+ const nextKey = keys[i + 1];
106
+ current[key] = !Number.isNaN(Number(nextKey)) ? [] : {};
107
+ }
108
+ current = current[key];
109
+ }
110
+ const lastKey = keys[keys.length - 1];
111
+ current[lastKey] = value;
112
+ return data;
113
+ }
@@ -8,8 +8,9 @@
8
8
  import { Label, useId } from 'bits-ui'
9
9
  import { formFieldVariants, formFieldDefaults } from './form-field.variants.js'
10
10
  import { getComponentConfig } from '../config.js'
11
- import { setContext } from 'svelte'
12
- import type { FormFieldContext } from '../hooks/useFormField.svelte.js'
11
+ import { setContext, untrack } from 'svelte'
12
+ import { type FormFieldContext, FORM_FIELD_CONTEXT_KEY } from '../hooks/useFormField.svelte.js'
13
+ import { getFormContext } from '../Form/form.context.svelte.js'
13
14
 
14
15
  const config = getComponentConfig('formField', formFieldDefaults)
15
16
 
@@ -25,6 +26,9 @@
25
26
  size = config.defaultVariants.size ?? 'md',
26
27
  required = false,
27
28
  orientation = config.defaultVariants.orientation ?? 'vertical',
29
+ eagerValidation,
30
+ validateOnInputDelay,
31
+ errorPattern,
28
32
  class: className,
29
33
  children,
30
34
  labelSlot,
@@ -38,6 +42,56 @@
38
42
  const id = useId()
39
43
  const ariaId = $derived(name ? `form-field-${name}` : id)
40
44
 
45
+ // Form-level context (undefined when FormField is used standalone).
46
+ const formCtx = getFormContext()
47
+
48
+ // Resolve error: explicit `error` prop always wins; otherwise ask the Form for
49
+ // errors matching this field's name (or errorPattern regex, if provided).
50
+ const resolvedError = $derived.by<string | boolean | undefined>(() => {
51
+ if (error !== undefined) return error
52
+ if (!formCtx || !name) return undefined
53
+ const errs = errorPattern ? formCtx.getErrors(errorPattern) : formCtx.getErrors(name)
54
+ return errs[0]?.message
55
+ })
56
+
57
+ // Register this field with the form. Split into two effects:
58
+ //
59
+ // 1. Lifecycle effect — tracks only `name`. Handles the initial registration
60
+ // and cleanup on unmount / rename. Reads other entry fields via `untrack`
61
+ // so they don't retrigger the lifecycle.
62
+ // 2. Update effect — tracks ariaId / errorPattern / eagerValidation /
63
+ // validateOnInputDelay and rewrites the entry via `.set()`. No cleanup —
64
+ // a single atomic overwrite, so no window where the registry is empty.
65
+ //
66
+ // Without this split, changing an unrelated prop (e.g. errorPattern) would
67
+ // cause cleanup→re-register, briefly leaving `#fieldRegistry.get(name)`
68
+ // undefined for any concurrent `#resolveErrorIds` call.
69
+ $effect(() => {
70
+ if (!formCtx || !name) return
71
+ const registeredName = name
72
+ untrack(() => {
73
+ formCtx.registerField(registeredName, {
74
+ id: ariaId,
75
+ pattern: errorPattern,
76
+ eagerValidation,
77
+ validateOnInputDelay
78
+ })
79
+ })
80
+ return () => {
81
+ formCtx.unregisterField(registeredName)
82
+ }
83
+ })
84
+
85
+ $effect(() => {
86
+ if (!formCtx || !name) return
87
+ formCtx.registerField(name, {
88
+ id: ariaId,
89
+ pattern: errorPattern,
90
+ eagerValidation,
91
+ validateOnInputDelay
92
+ })
93
+ })
94
+
41
95
  const variantSlots = $derived(formFieldVariants({ size, required, orientation }))
42
96
  const classes = $derived({
43
97
  root: variantSlots.root({ class: [config.slots.root, className, ui?.root] }),
@@ -55,10 +109,10 @@
55
109
  help: variantSlots.help({ class: [config.slots.help, ui?.help] })
56
110
  })
57
111
 
58
- const hasError = $derived(error !== undefined && error !== false)
59
- const errorMessage = $derived(typeof error === 'string' ? error : undefined)
112
+ const hasError = $derived(resolvedError !== undefined && resolvedError !== false)
113
+ const errorMessage = $derived(typeof resolvedError === 'string' ? resolvedError : undefined)
60
114
 
61
- setContext<FormFieldContext>('formField', {
115
+ setContext<FormFieldContext>(FORM_FIELD_CONTEXT_KEY, {
62
116
  get name() {
63
117
  return name
64
118
  },
@@ -66,10 +120,19 @@
66
120
  return size
67
121
  },
68
122
  get error() {
69
- return error
123
+ return resolvedError
70
124
  },
71
125
  get ariaId() {
72
126
  return ariaId
127
+ },
128
+ get eagerValidation() {
129
+ return eagerValidation
130
+ },
131
+ get validateOnInputDelay() {
132
+ return validateOnInputDelay
133
+ },
134
+ get errorPattern() {
135
+ return errorPattern
73
136
  }
74
137
  })
75
138
  </script>
@@ -103,12 +166,12 @@
103
166
 
104
167
  <div class={classes.container}>
105
168
  {#if children}
106
- {@render children({ error })}
169
+ {@render children({ error: resolvedError })}
107
170
  {/if}
108
171
 
109
172
  {#if hasError && (errorMessage || errorSlot)}
110
173
  {#if errorSlot}
111
- {@render errorSlot({ error })}
174
+ {@render errorSlot({ error: resolvedError })}
112
175
  {:else if errorMessage}
113
176
  <p id="{ariaId}-error" class={classes.error}>{errorMessage}</p>
114
177
  {/if}
@@ -47,6 +47,21 @@ export type FormFieldProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
47
47
  * @default 'vertical'
48
48
  */
49
49
  orientation?: NonNullable<FormFieldVariantProps['orientation']>;
50
+ /**
51
+ * When `true`, input events validate this field before first blur.
52
+ * By default, `input`-triggered validation only runs after the field has
53
+ * been blurred at least once.
54
+ */
55
+ eagerValidation?: boolean;
56
+ /**
57
+ * Per-field override for the form's `validateOnInputDelay` (debounce in ms).
58
+ */
59
+ validateOnInputDelay?: number;
60
+ /**
61
+ * Regex pattern to match form errors for this field (in addition to exact name match).
62
+ * Useful for array-style fields like `items.0.name`, `items.1.name`, etc.
63
+ */
64
+ errorPattern?: RegExp;
50
65
  /**
51
66
  * Additional CSS classes for the root element.
52
67
  */
@@ -1,10 +1,10 @@
1
1
  <script lang="ts" module>
2
- import type { InputProps } from './input.types.js'
2
+ import type { InputProps, InputValue } from './input.types.js'
3
3
 
4
- export type Props = InputProps
4
+ export type Props<T extends InputValue = InputValue> = InputProps<T>
5
5
  </script>
6
6
 
7
- <script lang="ts">
7
+ <script lang="ts" generics="T extends InputValue = InputValue">
8
8
  import { inputVariants, inputDefaults } from './input.variants.js'
9
9
  import { getComponentConfig, iconsDefaults } from '../config.js'
10
10
  import { getContext } from 'svelte'
@@ -15,7 +15,7 @@
15
15
  import Icon from '../Icon/Icon.svelte'
16
16
  import Avatar from '../Avatar/Avatar.svelte'
17
17
  import type { AvatarSize } from '../Avatar/avatar.types.js'
18
- import { useFormField } from '../hooks/useFormField.svelte.js'
18
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
19
19
 
20
20
  const config = getComponentConfig('input', inputDefaults)
21
21
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -42,10 +42,32 @@
42
42
  leadingSlot,
43
43
  trailingSlot,
44
44
  class: className,
45
+ onblur,
46
+ oninput,
47
+ onchange,
48
+ onfocus,
45
49
  ...restProps
46
- }: Props = $props()
50
+ }: Props<T> = $props()
47
51
 
48
52
  const formFieldContext = useFormField()
53
+ const emit = useFormFieldEmit()
54
+
55
+ function handleBlur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
56
+ emit.onBlur()
57
+ onblur?.(event)
58
+ }
59
+ function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
60
+ emit.onInput()
61
+ oninput?.(event)
62
+ }
63
+ function handleChange(event: Event & { currentTarget: HTMLInputElement }) {
64
+ emit.onChange()
65
+ onchange?.(event)
66
+ }
67
+ function handleFocus(event: FocusEvent & { currentTarget: HTMLInputElement }) {
68
+ emit.onFocus()
69
+ onfocus?.(event)
70
+ }
49
71
 
50
72
  const fieldGroupContext = getContext<
51
73
  | {
@@ -153,6 +175,10 @@
153
175
  aria-describedby={ariaDescribedBy}
154
176
  aria-invalid={resolvedHighlight ? true : undefined}
155
177
  class={classes.base}
178
+ onblur={handleBlur}
179
+ oninput={handleInput}
180
+ onchange={handleChange}
181
+ onfocus={handleFocus}
156
182
  />
157
183
 
158
184
  {#if trailingSlot}
@@ -1,5 +1,26 @@
1
- import type { InputProps } from './input.types.js';
2
- export type Props = InputProps;
3
- declare const Input: import("svelte").Component<InputProps, {}, "ref" | "value">;
4
- type Input = ReturnType<typeof Input>;
1
+ import type { InputProps, InputValue } from './input.types.js';
2
+ export type Props<T extends InputValue = InputValue> = InputProps<T>;
3
+ declare function $$render<T extends InputValue = InputValue>(): {
4
+ props: Props<T>;
5
+ exports: {};
6
+ bindings: "ref" | "value";
7
+ slots: {};
8
+ events: {};
9
+ };
10
+ declare class __sveltets_Render<T extends InputValue = InputValue> {
11
+ props(): ReturnType<typeof $$render<T>>['props'];
12
+ events(): ReturnType<typeof $$render<T>>['events'];
13
+ slots(): ReturnType<typeof $$render<T>>['slots'];
14
+ bindings(): "ref" | "value";
15
+ exports(): {};
16
+ }
17
+ interface $$IsomorphicComponent {
18
+ new <T extends InputValue = InputValue>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
19
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
20
+ } & ReturnType<__sveltets_Render<T>['exports']>;
21
+ <T extends InputValue = InputValue>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
22
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
23
+ }
24
+ declare const Input: $$IsomorphicComponent;
25
+ type Input<T extends InputValue = InputValue> = InstanceType<typeof Input<T>>;
5
26
  export default Input;
@@ -3,15 +3,36 @@ import type { HTMLInputAttributes } from 'svelte/elements';
3
3
  import type { ClassNameValue } from 'tailwind-merge';
4
4
  import type { InputVariantProps, InputSlots } from './input.variants.js';
5
5
  import type { AvatarProps } from '../Avatar/avatar.types.js';
6
- export type InputProps = Omit<HTMLInputAttributes, 'class' | 'size'> & {
6
+ /**
7
+ * Valid value types for `<Input bind:value>`.
8
+ *
9
+ * Covers every HTML input `type` except `"file"` (which uses `FileList` and is
10
+ * typically handled via `onchange` events, not `bind:value`). Pattern borrowed
11
+ * from Nuxt UI v4's `AcceptableValue` — it's the minimum union that covers
12
+ * real-world usage while still giving TypeScript enough to catch mistakes.
13
+ *
14
+ * Use with the component generic for strict typing:
15
+ * ```ts
16
+ * let age = $state<number | undefined>(undefined)
17
+ * <Input type="number" bind:value={age} /> // age stays number
18
+ * ```
19
+ */
20
+ export type InputValue = string | number | bigint | boolean | null | undefined;
21
+ export type InputProps<T extends InputValue = InputValue> = Omit<HTMLInputAttributes, 'class' | 'size' | 'value'> & {
7
22
  /**
8
23
  * Bind a reference to the underlying HTMLInputElement.
9
24
  */
10
25
  ref?: HTMLInputElement | null;
11
26
  /**
12
27
  * The current value of the input. Supports two-way binding with `bind:value`.
13
- */
14
- value?: string;
28
+ *
29
+ * The type is inferred from the bound variable — Svelte's `bind:value`
30
+ * coerces based on the `type` attribute automatically:
31
+ * - `string` for `text | email | password | url | search | tel | date | time | color` etc.
32
+ * - `number` for `type="number" | "range"`
33
+ * - `boolean` for hidden boolean state (rare)
34
+ */
35
+ value?: T;
15
36
  /**
16
37
  * Controls the visual style of the input.
17
38
  * @default 'outline'
@@ -8,7 +8,7 @@
8
8
  import { PinInput, useId } from 'bits-ui'
9
9
  import { pinInputVariants, pinInputDefaults } from './pin-input.variants.js'
10
10
  import { getComponentConfig } from '../config.js'
11
- import { useFormField } from '../hooks/useFormField.svelte.js'
11
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
12
12
 
13
13
  const config = getComponentConfig('pinInput', pinInputDefaults)
14
14
 
@@ -43,6 +43,7 @@
43
43
  }: Props = $props()
44
44
 
45
45
  const formFieldContext = useFormField()
46
+ const emit = useFormFieldEmit()
46
47
 
47
48
  const autoInputId = useId()
48
49
  const hasError = $derived(
@@ -69,9 +70,15 @@
69
70
  function handleValueChange(v: string) {
70
71
  const filtered = type === 'number' ? v.replace(/\D/g, '') : v
71
72
  value = filtered
73
+ emit.onInput()
72
74
  onValueChange?.(filtered)
73
75
  }
74
76
 
77
+ function handleComplete(v: string) {
78
+ emit.onChange()
79
+ onComplete?.(v)
80
+ }
81
+
75
82
  const slots = $derived(
76
83
  pinInputVariants({
77
84
  variant,
@@ -110,7 +117,7 @@
110
117
  maxlength={length}
111
118
  {disabled}
112
119
  {textalign}
113
- {onComplete}
120
+ onComplete={handleComplete}
114
121
  pasteTransformer={resolvedPasteTransformer}
115
122
  {pushPasswordManagerStrategy}
116
123
  inputId={resolvedInputId}
@@ -9,7 +9,7 @@
9
9
  import { radioGroupVariants, radioGroupDefaults } from './radio-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 { RadioGroupItem } from './radio-group.types.js'
14
14
 
15
15
  const config = getComponentConfig('radioGroup', radioGroupDefaults)
@@ -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
@@ -159,10 +160,23 @@
159
160
  {/if}
160
161
  {/snippet}
161
162
 
162
- <div {...restProps} bind:this={ref} class={layoutClasses.root}>
163
+ <div
164
+ {...restProps}
165
+ bind:this={ref}
166
+ class={layoutClasses.root}
167
+ onfocusin={() => emit.onFocus()}
168
+ onfocusout={(e) => {
169
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
170
+ emit.onBlur()
171
+ }
172
+ }}
173
+ >
163
174
  <RadioGroup.Root
164
175
  bind:value
165
- {onValueChange}
176
+ onValueChange={(val) => {
177
+ emit.onChange()
178
+ onValueChange?.(val)
179
+ }}
166
180
  id={resolvedId}
167
181
  name={resolvedName}
168
182
  disabled={isDisabled}
@@ -16,7 +16,7 @@
16
16
  import Icon from '../Icon/Icon.svelte'
17
17
  import Avatar from '../Avatar/Avatar.svelte'
18
18
  import type { AvatarSize } from '../Avatar/avatar.types.js'
19
- import { useFormField } from '../hooks/useFormField.svelte.js'
19
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
20
20
 
21
21
  const config = getComponentConfig('select', selectDefaults)
22
22
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -69,6 +69,7 @@
69
69
 
70
70
  // ---- Form context ----
71
71
  const formFieldContext = useFormField()
72
+ const emit = useFormFieldEmit()
72
73
 
73
74
  const fieldGroupContext = getContext<
74
75
  | {
@@ -317,12 +318,22 @@
317
318
  <Select.Root
318
319
  type="single"
319
320
  bind:open
320
- onOpenChange={(val) => onOpenChange?.(val)}
321
+ onOpenChange={(val) => {
322
+ if (val) {
323
+ emit.onFocus()
324
+ } else {
325
+ emit.onBlur()
326
+ }
327
+ onOpenChange?.(val)
328
+ }}
321
329
  {disabled}
322
330
  {required}
323
331
  items={bitsItems}
324
332
  {value}
325
- onValueChange={(val) => (value = val)}
333
+ onValueChange={(val) => {
334
+ value = val
335
+ emit.onChange()
336
+ }}
326
337
  >
327
338
  <div bind:this={ref} class={rootClass}>
328
339
  {#if leadingSlot}
@@ -21,7 +21,7 @@
21
21
  import Avatar from '../Avatar/Avatar.svelte'
22
22
  import Input from '../Input/Input.svelte'
23
23
  import type { AvatarSize } from '../Avatar/avatar.types.js'
24
- import { useFormField } from '../hooks/useFormField.svelte.js'
24
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
25
25
 
26
26
  const config = getComponentConfig('selectMenu', selectMenuDefaults)
27
27
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -78,6 +78,7 @@
78
78
 
79
79
  // ---- Form context ----
80
80
  const formFieldContext = useFormField()
81
+ const emit = useFormFieldEmit()
81
82
 
82
83
  const fieldGroupContext = getContext<
83
84
  | {
@@ -257,6 +258,9 @@
257
258
  function onUpdateOpen(val: boolean) {
258
259
  if (!val) {
259
260
  searchTerm = ''
261
+ emit.onBlur()
262
+ } else {
263
+ emit.onFocus()
260
264
  }
261
265
  onOpenChange?.(val)
262
266
  }
@@ -366,7 +370,10 @@
366
370
  {disabled}
367
371
  {required}
368
372
  {value}
369
- onValueChange={(val) => (value = val)}
373
+ onValueChange={(val) => {
374
+ value = val
375
+ emit.onChange()
376
+ }}
370
377
  name={resolvedName}
371
378
  >
372
379
  <div bind:this={ref} class={rootClass}>
@@ -8,7 +8,7 @@
8
8
  import { Slider, useId } from 'bits-ui'
9
9
  import { sliderVariants, sliderDefaults } from './slider.variants.js'
10
10
  import { getComponentConfig } from '../config.js'
11
- import { useFormField } from '../hooks/useFormField.svelte.js'
11
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
12
12
 
13
13
  const config = getComponentConfig('slider', sliderDefaults)
14
14
 
@@ -37,6 +37,7 @@
37
37
  }: Props = $props()
38
38
 
39
39
  const formFieldContext = useFormField()
40
+ const emit = useFormFieldEmit()
40
41
 
41
42
  const autoId = useId()
42
43
  const hasError = $derived(
@@ -59,10 +60,12 @@
59
60
 
60
61
  function handleValueChange(v: number[]) {
61
62
  value = isMultiple ? v : (v[0] ?? min)
63
+ emit.onInput()
62
64
  onValueChange?.(value)
63
65
  }
64
66
 
65
67
  function handleValueCommit(v: number[]) {
68
+ emit.onChange()
66
69
  onValueCommit?.(isMultiple ? v : (v[0] ?? min))
67
70
  }
68
71
 
@@ -9,7 +9,7 @@
9
9
  import { switchVariants, switchDefaults } from './switch.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('switch', switchDefaults)
15
15
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -39,6 +39,7 @@
39
39
  }: Props = $props()
40
40
 
41
41
  const formFieldContext = useFormField()
42
+ const emit = useFormFieldEmit()
42
43
 
43
44
  const hasError = $derived(
44
45
  formFieldContext?.error !== undefined && formFieldContext?.error !== false
@@ -108,7 +109,12 @@
108
109
  <div class={classes.container}>
109
110
  <Switch.Root
110
111
  bind:checked
111
- {onCheckedChange}
112
+ onCheckedChange={(val) => {
113
+ emit.onChange()
114
+ onCheckedChange?.(val)
115
+ }}
116
+ onblur={() => emit.onBlur()}
117
+ onfocus={() => emit.onFocus()}
112
118
  id={resolvedId}
113
119
  name={resolvedName}
114
120
  {value}