@samuel-charpentier/sform 0.0.2 → 0.0.4

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.
@@ -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.
@@ -41,6 +42,10 @@
41
42
  // Register this field with the context on mount
42
43
  $effect(() => {
43
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
+ }
44
49
  });
45
50
 
46
51
  const classes: SfieldClasses = $derived(
@@ -112,7 +117,6 @@
112
117
  'month',
113
118
  'week',
114
119
  'color',
115
- 'hidden',
116
120
  'file'
117
121
  ].includes(props.type)
118
122
  );
@@ -147,6 +151,8 @@
147
151
  <ToggleOptionsInput {...passthroughProps()} {...internalProps} />
148
152
  {:else if props.type === 'masked'}
149
153
  <MaskedInput {...passthroughProps()} {...internalProps} />
154
+ {:else if props.type === 'hidden'}
155
+ <HiddenInput {...passthroughProps()} {...internalProps} />
150
156
  {/if}
151
157
 
152
158
  {#if props.hint}
@@ -60,7 +60,8 @@
60
60
  () => {
61
61
  // Use setTimeout to ensure we're outside the current event loop
62
62
  setTimeout(() => formElement?.requestSubmit(), 0);
63
- }
63
+ },
64
+ () => form
64
65
  );
65
66
 
66
67
  // Apply preflight schema if provided
@@ -1,3 +1,12 @@
1
- import type { SformContext, ValidateOn } from './types.js';
2
- export declare function createSformContext(getValidateOn: () => ValidateOn, getFieldNames: () => string[], triggerValidation: () => void, submitForm: () => 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,10 +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, submitForm) {
4
+ export function createSformContext(getValidateOn, getFieldNames, triggerValidation, submitForm, getForm) {
5
5
  const touched = new SvelteSet();
6
6
  const dirty = new SvelteSet();
7
7
  const registeredFields = new SvelteSet();
8
+ const fieldsWithIssueDisplay = new SvelteSet();
8
9
  let submitted = $state(false);
9
10
  const context = {
10
11
  get validateOn() {
@@ -51,12 +52,71 @@ export function createSformContext(getValidateOn, getFieldNames, triggerValidati
51
52
  registerField: (name) => {
52
53
  registeredFields.add(name);
53
54
  },
55
+ registerFieldWithIssueDisplay: (name) => {
56
+ fieldsWithIssueDisplay.add(name);
57
+ },
54
58
  resetFieldStates: () => {
55
59
  touched.clear();
56
60
  dirty.clear();
57
61
  submitted = false;
58
62
  },
59
- submitForm
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
+ });
119
+ }
60
120
  };
61
121
  setContext(SFORM_CONTEXT_KEY, context);
62
122
  return 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,20 +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
4
  import { getSformContext } from '../context.svelte.js';
5
5
 
6
- 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;
7
12
  pending?: number;
8
- result?: unknown;
9
13
  fields: {
10
14
  allIssues?: () => RemoteFormIssue[] | undefined;
15
+ [key: string]: unknown;
11
16
  };
12
17
  }
13
18
 
14
19
  interface Props {
15
- /** The remote form - required for button state */
16
- form: FormLike;
17
- /** 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) */
18
23
  label?: string;
19
24
  /** Button type */
20
25
  buttonType?: 'submit' | 'reset' | 'button';
@@ -22,55 +27,37 @@
22
27
  class?: string;
23
28
  /** Whether button is disabled */
24
29
  disabled?: boolean;
30
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
31
+ children?: Snippet<[ButtonState<T>]>;
25
32
  /** Callback that runs before validation/submission (can be async) */
26
33
  onsubmit?: () => void | Promise<void>;
27
- /** Snippet for default state */
28
- defaultState?: Snippet<[ButtonFormState]>;
29
- /** Snippet for pending state */
30
- pendingState?: Snippet<[ButtonFormState]>;
31
- /** Snippet for success state */
32
- successState?: Snippet<[ButtonFormState]>;
33
- /** Snippet for error/issues state */
34
- errorState?: Snippet<[ButtonFormState]>;
35
34
  }
36
35
 
37
36
  let {
38
- form,
39
37
  label = 'Submit',
40
38
  buttonType = 'submit',
41
39
  class: className,
42
40
  disabled = false,
43
- onsubmit,
44
- defaultState,
45
- pendingState,
46
- successState,
47
- errorState
41
+ children,
42
+ onsubmit
48
43
  }: Props = $props();
49
44
 
50
- // Get Sform context to trigger validation display
45
+ // Get Sform context for form state and actions
51
46
  const sformContext = getSformContext();
52
47
 
53
- const formState: ButtonFormState = $derived.by(() => {
54
- const pending = (form.pending ?? 0) !== 0;
55
- const hasResult = form.result !== undefined;
56
- const issues = form.fields.allIssues?.() ?? [];
57
- const hasIssues = issues.length > 0;
58
-
59
- return {
60
- pending,
61
- success: hasResult && !hasIssues && !pending,
62
- hasIssues,
63
- result: form.result
64
- };
65
- });
48
+ // Get form state from context with the generic type
49
+ const formState = $derived.by(sformContext.getFormState<T>);
66
50
 
67
51
  const isDisabled = $derived(disabled || formState.pending);
68
52
 
69
53
  async function handleClick(event: MouseEvent) {
70
- if (buttonType !== 'submit' || !onsubmit) return;
54
+ if (buttonType !== 'submit') return;
71
55
 
72
56
  event.preventDefault();
73
- await onsubmit();
57
+
58
+ if (onsubmit) {
59
+ await onsubmit();
60
+ }
74
61
 
75
62
  // Mark form as submitted and all fields dirty so issues display when server responds
76
63
  sformContext.markSubmitted();
@@ -82,14 +69,8 @@
82
69
  </script>
83
70
 
84
71
  <button type={buttonType} class={className} disabled={isDisabled} onclick={handleClick}>
85
- {#if formState.pending && pendingState}
86
- {@render pendingState(formState)}
87
- {:else if formState.success && successState}
88
- {@render successState(formState)}
89
- {:else if formState.hasIssues && errorState}
90
- {@render errorState(formState)}
91
- {:else if defaultState}
92
- {@render defaultState(formState)}
72
+ {#if children}
73
+ {@render children(formState)}
93
74
  {:else}
94
75
  {label}
95
76
  {/if}
@@ -1,34 +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
- /** Callback that runs before validation/submission (can be async) */
22
- onsubmit?: () => void | Promise<void>;
23
- /** Snippet for default state */
24
- defaultState?: Snippet<[ButtonFormState]>;
25
- /** Snippet for pending state */
26
- pendingState?: Snippet<[ButtonFormState]>;
27
- /** Snippet for success state */
28
- successState?: Snippet<[ButtonFormState]>;
29
- /** Snippet for error/issues state */
30
- 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']>;
31
45
  }
32
- declare const ButtonInput: import("svelte").Component<Props, {}, "">;
33
- type ButtonInput = ReturnType<typeof ButtonInput>;
46
+ declare const ButtonInput: $$IsomorphicComponent;
47
+ type ButtonInput<T = unknown> = InstanceType<typeof ButtonInput<T>>;
34
48
  export default ButtonInput;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { CheckboxGroupInputProps, SelectOption } from '../types.js';
3
+ import Fieldset from '../utils/Fieldset.svelte';
3
4
 
4
5
  let {
5
6
  field,
@@ -29,10 +30,7 @@
29
30
  }
30
31
  </script>
31
32
 
32
- <fieldset class="sform-checkbox-group {className ?? ''}" {disabled}>
33
- {#if label}
34
- <legend class={labelClass}>{label}</legend>
35
- {/if}
33
+ <Fieldset {label} {labelClass} {className} {disabled}>
36
34
  {#each normalizedOptions as option}
37
35
  {@const fieldAttrs = getFieldAttrs(option.value)}
38
36
  {@const uniqueId = `${name}-${option.value}`}
@@ -48,20 +46,9 @@
48
46
  <span class="sform-checkbox-label">{option.label}</span>
49
47
  </label>
50
48
  {/each}
51
- </fieldset>
49
+ </Fieldset>
52
50
 
53
51
  <style>
54
- .sform-checkbox-group {
55
- border: none;
56
- padding: 0;
57
- margin: 0;
58
- }
59
-
60
- .sform-checkbox-group legend {
61
- padding: 0;
62
- margin-bottom: 0.5rem;
63
- }
64
-
65
52
  .sform-checkbox-option {
66
53
  display: flex;
67
54
  align-items: center;
@@ -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;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { RadioInputProps, SelectOption } from '../types.js';
3
+ import Fieldset from '../utils/Fieldset.svelte';
3
4
 
4
5
  let {
5
6
  field,
@@ -29,10 +30,7 @@
29
30
  }
30
31
  </script>
31
32
 
32
- <fieldset class="sform-radio-group {className ?? ''}" {disabled}>
33
- {#if label}
34
- <legend class={labelClass}>{label}</legend>
35
- {/if}
33
+ <Fieldset {label} {labelClass} {className} {disabled}>
36
34
  {#each normalizedOptions as option}
37
35
  {@const fieldAttrs = getFieldAttrs(option.value)}
38
36
  {@const uniqueId = `${name}-${option.value}`}
@@ -48,7 +46,7 @@
48
46
  <span class="sform-radio-label">{option.label}</span>
49
47
  </label>
50
48
  {/each}
51
- </fieldset>
49
+ </Fieldset>
52
50
 
53
51
  <style>
54
52
  .sform-radio-group {
@@ -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
  =========================================== */