@samuel-charpentier/sform 0.0.1 → 0.0.3

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,39 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { RemoteFormIssue } from './types.js';
3
+ declare function $$render<T = unknown>(): {
4
+ props: {
5
+ /** The remote form - used to infer the result type T */
6
+ form: {
7
+ result?: T | undefined;
8
+ fields: {
9
+ allIssues?: () => RemoteFormIssue[] | undefined;
10
+ [key: string]: unknown;
11
+ };
12
+ };
13
+ /** Children snippet receives the result (guaranteed to be defined) */
14
+ children: Snippet<[T]>;
15
+ /** CSS class for the wrapper */
16
+ class?: string;
17
+ };
18
+ exports: {};
19
+ bindings: "";
20
+ slots: {};
21
+ events: {};
22
+ };
23
+ declare class __sveltets_Render<T = unknown> {
24
+ props(): ReturnType<typeof $$render<T>>['props'];
25
+ events(): ReturnType<typeof $$render<T>>['events'];
26
+ slots(): ReturnType<typeof $$render<T>>['slots'];
27
+ bindings(): "";
28
+ exports(): {};
29
+ }
30
+ interface $$IsomorphicComponent {
31
+ new <T = unknown>(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']>> & {
32
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
33
+ } & ReturnType<__sveltets_Render<T>['exports']>;
34
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
35
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
36
+ }
37
+ declare const SResult: $$IsomorphicComponent;
38
+ type SResult<T = unknown> = InstanceType<typeof SResult<T>>;
39
+ export default SResult;
@@ -18,6 +18,7 @@
18
18
  import ToggleOptionsInput from './inputs/ToggleOptionsInput.svelte';
19
19
  import MaskedInput from './inputs/MaskedInput.svelte';
20
20
  import PasswordInput from './inputs/PasswordInput.svelte';
21
+ import HiddenInput from './inputs/HiddenInput.svelte';
21
22
 
22
23
  /**
23
24
  * Sfield - Type-safe form field component.
@@ -38,6 +39,15 @@
38
39
  // Derive name from the field - all field types include name in their .as() output
39
40
  const name = $derived(field.as('text').name);
40
41
 
42
+ // Register this field with the context on mount
43
+ $effect(() => {
44
+ context.registerField(name);
45
+ // Register for issue display tracking (all types except hidden display their issues)
46
+ if (props.type !== 'hidden') {
47
+ context.registerFieldWithIssueDisplay(name);
48
+ }
49
+ });
50
+
41
51
  const classes: SfieldClasses = $derived(
42
52
  typeof props.class === 'string' ? { wrapper: props.class } : (props.class ?? {})
43
53
  );
@@ -107,7 +117,6 @@
107
117
  'month',
108
118
  'week',
109
119
  'color',
110
- 'hidden',
111
120
  'file'
112
121
  ].includes(props.type)
113
122
  );
@@ -142,6 +151,8 @@
142
151
  <ToggleOptionsInput {...passthroughProps()} {...internalProps} />
143
152
  {:else if props.type === 'masked'}
144
153
  <MaskedInput {...passthroughProps()} {...internalProps} />
154
+ {:else if props.type === 'hidden'}
155
+ <HiddenInput {...passthroughProps()} {...internalProps} />
145
156
  {/if}
146
157
 
147
158
  {#if props.hint}
@@ -53,7 +53,16 @@
53
53
  form.validate({ includeUntouched: true });
54
54
  };
55
55
 
56
- const context = createSformContext(() => validateOn, getFieldNames, triggerValidation);
56
+ const context = createSformContext(
57
+ () => validateOn,
58
+ getFieldNames,
59
+ triggerValidation,
60
+ () => {
61
+ // Use setTimeout to ensure we're outside the current event loop
62
+ setTimeout(() => formElement?.requestSubmit(), 0);
63
+ },
64
+ () => form
65
+ );
57
66
 
58
67
  // Apply preflight schema if provided
59
68
  const formWithSchema = $derived(schema ? form.preflight(schema) : form);
@@ -88,9 +97,12 @@
88
97
  context.markSubmitted();
89
98
  context.markAllFieldsDirty();
90
99
  }
100
+
101
+ let formElement: HTMLFormElement | undefined = $state();
91
102
  </script>
92
103
 
93
104
  <form
105
+ bind:this={formElement}
94
106
  {...formProps as unknown as HTMLFormAttributes}
95
107
  class={className}
96
108
  novalidate
@@ -1,3 +1,12 @@
1
- import type { SformContext, ValidateOn } from './types.js';
2
- export declare function createSformContext(getValidateOn: () => ValidateOn, getFieldNames: () => string[], triggerValidation: () => void): SformContext;
1
+ import type { RemoteFormIssue, SformContext, ValidateOn } from './types.js';
2
+ interface FormLike {
3
+ pending?: number;
4
+ result?: unknown;
5
+ fields: {
6
+ allIssues?: () => RemoteFormIssue[] | undefined;
7
+ [key: string]: unknown;
8
+ };
9
+ }
10
+ export declare function createSformContext(getValidateOn: () => ValidateOn, getFieldNames: () => string[], triggerValidation: () => void, submitForm: () => void, getForm: () => FormLike): SformContext;
3
11
  export declare function getSformContext(): SformContext;
12
+ export {};
@@ -1,9 +1,11 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  import { SvelteSet } from 'svelte/reactivity';
3
3
  const SFORM_CONTEXT_KEY = Symbol('sform-context');
4
- export function createSformContext(getValidateOn, getFieldNames, triggerValidation) {
4
+ export function createSformContext(getValidateOn, getFieldNames, triggerValidation, submitForm, getForm) {
5
5
  const touched = new SvelteSet();
6
6
  const dirty = new SvelteSet();
7
+ const registeredFields = new SvelteSet();
8
+ const fieldsWithIssueDisplay = new SvelteSet();
7
9
  let submitted = $state(false);
8
10
  const context = {
9
11
  get validateOn() {
@@ -40,17 +42,80 @@ export function createSformContext(getValidateOn, getFieldNames, triggerValidati
40
42
  submitted = true;
41
43
  },
42
44
  markAllFieldsDirty: () => {
43
- // Get all field names from the form and mark them as touched and dirty
44
- const fieldNames = getFieldNames();
45
+ // Use registered fields from Sfield components
46
+ const fieldNames = [...registeredFields];
45
47
  for (const name of fieldNames) {
46
48
  touched.add(name);
47
49
  dirty.add(name);
48
50
  }
49
51
  },
52
+ registerField: (name) => {
53
+ registeredFields.add(name);
54
+ },
55
+ registerFieldWithIssueDisplay: (name) => {
56
+ fieldsWithIssueDisplay.add(name);
57
+ },
50
58
  resetFieldStates: () => {
51
59
  touched.clear();
52
60
  dirty.clear();
53
61
  submitted = false;
62
+ },
63
+ submitForm,
64
+ getFormState: () => {
65
+ const form = getForm();
66
+ const pending = (form.pending ?? 0) !== 0;
67
+ const hasResult = form.result !== undefined;
68
+ const issues = form.fields.allIssues?.() ?? [];
69
+ const hasIssues = issues.length > 0;
70
+ if (pending) {
71
+ return {
72
+ state: 'pending',
73
+ pending: true,
74
+ success: false,
75
+ hasIssues: false,
76
+ result: undefined
77
+ };
78
+ }
79
+ if (hasResult) {
80
+ // If there's a result, it's success (issues come via invalid() which throws, no result)
81
+ return {
82
+ state: 'success',
83
+ pending: false,
84
+ success: true,
85
+ hasIssues: false,
86
+ result: form.result
87
+ };
88
+ }
89
+ if (hasIssues) {
90
+ return {
91
+ state: 'hasIssues',
92
+ pending: false,
93
+ success: false,
94
+ hasIssues: true,
95
+ result: undefined
96
+ };
97
+ }
98
+ return {
99
+ state: 'default',
100
+ pending: false,
101
+ success: false,
102
+ hasIssues: false,
103
+ result: undefined
104
+ };
105
+ },
106
+ getUnhandledIssues: () => {
107
+ const form = getForm();
108
+ const allIssues = form.fields.allIssues?.() ?? [];
109
+ // Filter out issues that are linked to fields with issue display
110
+ return allIssues.filter((issue) => {
111
+ // If issue has no path, it's a form-level issue (from invalid("message"))
112
+ if (!issue.path || issue.path.length === 0) {
113
+ return true;
114
+ }
115
+ // Check if the first path segment is a field that displays issues
116
+ const fieldName = String(issue.path[0]);
117
+ return !fieldsWithIssueDisplay.has(fieldName);
118
+ });
54
119
  }
55
120
  };
56
121
  setContext(SFORM_CONTEXT_KEY, context);
@@ -1,5 +1,7 @@
1
1
  export { default as Sform } from './Sform.svelte';
2
2
  export { default as Sfield } from './Sfield.svelte';
3
3
  export { default as Sbutton } from './inputs/ButtonInput.svelte';
4
+ export { default as SIssues } from './SIssues.svelte';
5
+ export { default as SResult } from './SResult.svelte';
4
6
  export { applyMask, unmask, MASK_PATTERNS, DEFAULT_TOKENS, type MaskOptions, type MaskResult, type MaskToken, type MaskPattern } from './utils/mask.js';
5
- export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonFormState, ButtonInputProps, InputAffixProps, RangeInputProps, ToggleInputProps, ToggleOption, ToggleOptionsInputProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './types.js';
7
+ export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonState, ButtonFormState, ButtonInputProps, SIssuesProps, InputAffixProps, RangeInputProps, ToggleInputProps, ToggleOption, ToggleOptionsInputProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './types.js';
@@ -3,5 +3,7 @@ export { default as Sform } from './Sform.svelte';
3
3
  export { default as Sfield } from './Sfield.svelte';
4
4
  // Standalone components (not routed through Sfield)
5
5
  export { default as Sbutton } from './inputs/ButtonInput.svelte';
6
+ export { default as SIssues } from './SIssues.svelte';
7
+ export { default as SResult } from './SResult.svelte';
6
8
  // Utilities
7
9
  export { applyMask, unmask, MASK_PATTERNS, DEFAULT_TOKENS } from './utils/mask.js';
@@ -1,19 +1,25 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="T = unknown">
2
2
  import type { Snippet } from 'svelte';
3
- import type { ButtonFormState, RemoteFormIssue } from '../types.js';
3
+ import type { ButtonState, RemoteFormIssue } from '../types.js';
4
+ import { getSformContext } from '../context.svelte.js';
4
5
 
5
- interface FormLike {
6
+ /**
7
+ * Minimal form shape needed for type inference.
8
+ * This allows the component to infer T from the form's result type.
9
+ */
10
+ interface FormLike<Output> {
11
+ result?: Output;
6
12
  pending?: number;
7
- result?: unknown;
8
13
  fields: {
9
14
  allIssues?: () => RemoteFormIssue[] | undefined;
15
+ [key: string]: unknown;
10
16
  };
11
17
  }
12
18
 
13
19
  interface Props {
14
- /** The remote form - required for button state */
15
- form: FormLike;
16
- /** Button text (used if no snippets provided) */
20
+ /** The remote form - used to infer the result type T */
21
+ form: FormLike<T>;
22
+ /** Button text (used if no children snippet provided) */
17
23
  label?: string;
18
24
  /** Button type */
19
25
  buttonType?: 'submit' | 'reset' | 'button';
@@ -21,54 +27,50 @@
21
27
  class?: string;
22
28
  /** Whether button is disabled */
23
29
  disabled?: boolean;
24
- /** Snippet for default state */
25
- defaultState?: Snippet<[ButtonFormState]>;
26
- /** Snippet for pending state */
27
- pendingState?: Snippet<[ButtonFormState]>;
28
- /** Snippet for success state */
29
- successState?: Snippet<[ButtonFormState]>;
30
- /** Snippet for error/issues state */
31
- errorState?: Snippet<[ButtonFormState]>;
30
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
31
+ children?: Snippet<[ButtonState<T>]>;
32
+ /** Callback that runs before validation/submission (can be async) */
33
+ onsubmit?: () => void | Promise<void>;
32
34
  }
33
35
 
34
36
  let {
35
- form,
36
37
  label = 'Submit',
37
38
  buttonType = 'submit',
38
39
  class: className,
39
40
  disabled = false,
40
- defaultState,
41
- pendingState,
42
- successState,
43
- errorState
41
+ children,
42
+ onsubmit
44
43
  }: Props = $props();
45
44
 
46
- const formState: ButtonFormState = $derived.by(() => {
47
- const pending = (form.pending ?? 0) !== 0;
48
- const hasResult = form.result !== undefined;
49
- const issues = form.fields.allIssues?.() ?? [];
50
- const hasIssues = issues.length > 0;
45
+ // Get Sform context for form state and actions
46
+ const sformContext = getSformContext();
51
47
 
52
- return {
53
- pending,
54
- success: hasResult && !hasIssues && !pending,
55
- hasIssues,
56
- result: form.result
57
- };
58
- });
48
+ // Get form state from context with the generic type
49
+ const formState = $derived.by(sformContext.getFormState<T>);
59
50
 
60
51
  const isDisabled = $derived(disabled || formState.pending);
52
+
53
+ async function handleClick(event: MouseEvent) {
54
+ if (buttonType !== 'submit') return;
55
+
56
+ event.preventDefault();
57
+
58
+ if (onsubmit) {
59
+ await onsubmit();
60
+ }
61
+
62
+ // Mark form as submitted and all fields dirty so issues display when server responds
63
+ sformContext.markSubmitted();
64
+ sformContext.markAllFieldsDirty();
65
+
66
+ // Submit the form via context
67
+ sformContext.submitForm();
68
+ }
61
69
  </script>
62
70
 
63
- <button type={buttonType} class={className} disabled={isDisabled}>
64
- {#if formState.pending && pendingState}
65
- {@render pendingState(formState)}
66
- {:else if formState.success && successState}
67
- {@render successState(formState)}
68
- {:else if formState.hasIssues && errorState}
69
- {@render errorState(formState)}
70
- {:else if defaultState}
71
- {@render defaultState(formState)}
71
+ <button type={buttonType} class={className} disabled={isDisabled} onclick={handleClick}>
72
+ {#if children}
73
+ {@render children(formState)}
72
74
  {:else}
73
75
  {label}
74
76
  {/if}
@@ -1,32 +1,48 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { ButtonFormState, RemoteFormIssue } from '../types.js';
3
- interface FormLike {
4
- pending?: number;
5
- result?: unknown;
6
- fields: {
7
- allIssues?: () => RemoteFormIssue[] | undefined;
2
+ import type { ButtonState, RemoteFormIssue } from '../types.js';
3
+ declare function $$render<T = unknown>(): {
4
+ props: {
5
+ /** The remote form - used to infer the result type T */
6
+ form: {
7
+ result?: T | undefined;
8
+ pending?: number;
9
+ fields: {
10
+ allIssues?: () => RemoteFormIssue[] | undefined;
11
+ [key: string]: unknown;
12
+ };
13
+ };
14
+ /** Button text (used if no children snippet provided) */
15
+ label?: string;
16
+ /** Button type */
17
+ buttonType?: "submit" | "reset" | "button";
18
+ /** Button class */
19
+ class?: string;
20
+ /** Whether button is disabled */
21
+ disabled?: boolean;
22
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
23
+ children?: Snippet<[ButtonState<T>]>;
24
+ /** Callback that runs before validation/submission (can be async) */
25
+ onsubmit?: () => void | Promise<void>;
8
26
  };
27
+ exports: {};
28
+ bindings: "";
29
+ slots: {};
30
+ events: {};
31
+ };
32
+ declare class __sveltets_Render<T = unknown> {
33
+ props(): ReturnType<typeof $$render<T>>['props'];
34
+ events(): ReturnType<typeof $$render<T>>['events'];
35
+ slots(): ReturnType<typeof $$render<T>>['slots'];
36
+ bindings(): "";
37
+ exports(): {};
9
38
  }
10
- interface Props {
11
- /** The remote form - required for button state */
12
- form: FormLike;
13
- /** Button text (used if no snippets provided) */
14
- label?: string;
15
- /** Button type */
16
- buttonType?: 'submit' | 'reset' | 'button';
17
- /** Button class */
18
- class?: string;
19
- /** Whether button is disabled */
20
- disabled?: boolean;
21
- /** Snippet for default state */
22
- defaultState?: Snippet<[ButtonFormState]>;
23
- /** Snippet for pending state */
24
- pendingState?: Snippet<[ButtonFormState]>;
25
- /** Snippet for success state */
26
- successState?: Snippet<[ButtonFormState]>;
27
- /** Snippet for error/issues state */
28
- errorState?: Snippet<[ButtonFormState]>;
39
+ interface $$IsomorphicComponent {
40
+ new <T = unknown>(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']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
29
45
  }
30
- declare const ButtonInput: import("svelte").Component<Props, {}, "">;
31
- type ButtonInput = ReturnType<typeof ButtonInput>;
46
+ declare const ButtonInput: $$IsomorphicComponent;
47
+ type ButtonInput<T = unknown> = InstanceType<typeof ButtonInput<T>>;
32
48
  export default ButtonInput;
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { HiddenInputProps } from '../types.js';
3
+
4
+ let { field, name, value }: HiddenInputProps = $props();
5
+
6
+ // Hidden fields require the value to be passed to field.as('hidden', value)
7
+ const fieldAttrs = $derived(field.as('hidden', value ?? ''));
8
+ </script>
9
+
10
+ <input {...fieldAttrs} type="hidden" id={name} />
@@ -0,0 +1,4 @@
1
+ import type { HiddenInputProps } from '../types.js';
2
+ declare const HiddenInput: import("svelte").Component<HiddenInputProps, {}, "">;
3
+ type HiddenInput = ReturnType<typeof HiddenInput>;
4
+ export default HiddenInput;
@@ -28,34 +28,30 @@
28
28
  let inputElement: HTMLInputElement | undefined = $state();
29
29
  </script>
30
30
 
31
- {#if type !== 'hidden'}
32
- {#if label}
33
- <label class={labelClass} for={name}>{label}</label>
34
- {/if}
35
- <div class="sform-input-wrapper {wrapperClass ?? ''}">
36
- {#if prefix}
37
- <div class="sform-prefix" onclick={() => inputElement?.focus()} role="presentation">
38
- {#if typeof prefix === 'function'}{@render prefix()}{:else}{prefix}{/if}
39
- </div>
40
- {/if}
41
- <input
42
- bind:this={inputElement}
43
- {...fieldAttrs}
44
- id={name}
45
- class={className}
46
- {placeholder}
47
- {disabled}
48
- {readonly}
49
- {autocomplete}
50
- {onblur}
51
- {oninput}
52
- />
53
- {#if suffix}
54
- <div class="sform-suffix" onclick={() => inputElement?.focus()} role="presentation">
55
- {#if typeof suffix === 'function'}{@render suffix()}{:else}{suffix}{/if}
56
- </div>
57
- {/if}
58
- </div>
59
- {:else}
60
- <input {...fieldAttrs} type="hidden" id={name} />
31
+ {#if label}
32
+ <label class={labelClass} for={name}>{label}</label>
61
33
  {/if}
34
+ <div class="sform-input-wrapper {wrapperClass ?? ''}">
35
+ {#if prefix}
36
+ <div class="sform-prefix" onclick={() => inputElement?.focus()} role="presentation">
37
+ {#if typeof prefix === 'function'}{@render prefix()}{:else}{prefix}{/if}
38
+ </div>
39
+ {/if}
40
+ <input
41
+ bind:this={inputElement}
42
+ {...fieldAttrs}
43
+ id={name}
44
+ class={className}
45
+ {placeholder}
46
+ {disabled}
47
+ {readonly}
48
+ {autocomplete}
49
+ {onblur}
50
+ {oninput}
51
+ />
52
+ {#if suffix}
53
+ <div class="sform-suffix" onclick={() => inputElement?.focus()} role="presentation">
54
+ {#if typeof suffix === 'function'}{@render suffix()}{:else}{suffix}{/if}
55
+ </div>
56
+ {/if}
57
+ </div>
@@ -600,6 +600,7 @@ select.sform-input {
600
600
  .sform-result-success {
601
601
  color: #065f46;
602
602
  background: var(--sform-success-bg);
603
+ margin-block-start: 1rem;
603
604
  }
604
605
 
605
606
  .sform-result-error {
@@ -612,6 +613,19 @@ select.sform-input {
612
613
  background: var(--sform-warning-bg);
613
614
  }
614
615
 
616
+ /* ===========================================
617
+ Issues List
618
+ =========================================== */
619
+
620
+ .sform-issues-message {
621
+ font-weight: bold;
622
+ margin-bottom: 0.25rem;
623
+ }
624
+ .sform-issues-list {
625
+ margin: 0;
626
+ padding-inline-start: 1.25rem;
627
+ }
628
+
615
629
  /* ===========================================
616
630
  Utilities
617
631
  =========================================== */