@samuel-charpentier/sform 1.0.0 → 1.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.
@@ -1,43 +1,26 @@
1
1
  <script lang="ts" generics="T = unknown">
2
- import { tick, type Snippet } from 'svelte';
3
- import type { ButtonState, RemoteFormIssue } from '../types.js';
2
+ import { tick } from 'svelte';
3
+ import type { ButtonInputProps } from '../types.js';
4
4
  import { getSformContext } from '../context.svelte.js';
5
5
 
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;
12
- pending?: number;
13
- fields: {
14
- allIssues?: () => RemoteFormIssue[] | undefined;
15
- [key: string]: unknown;
16
- };
17
- }
18
-
19
- interface Props {
20
- /** The remote form - used to infer the result type T */
21
- form: FormLike<T>;
22
- /** Button text (used if no children snippet provided) */
23
- label?: string | Snippet<[ButtonState<T>]>;
24
- /** Button type */
25
- buttonType?: 'submit' | 'reset' | 'button';
26
- /** Button class */
27
- class?: string;
28
- /** Whether button is disabled */
29
- disabled?: boolean;
30
- /** Callback that runs before validation/submission (can be async) */
31
- onsubmit?: () => void | Promise<void>;
32
- }
33
-
34
6
  let {
7
+ form,
35
8
  label = 'Submit',
36
9
  buttonType = 'submit',
37
10
  class: className,
38
11
  disabled = false,
39
- onsubmit
40
- }: Props = $props();
12
+ onsubmit,
13
+ children
14
+ }: ButtonInputProps<T> = $props();
15
+
16
+ $effect(() => {
17
+ // Keep the prop "form" observably consumed for strict compiler checks.
18
+ void form.result;
19
+ void form.pending;
20
+ void form.fields;
21
+ });
22
+
23
+ const hasForm = $derived(form !== undefined);
41
24
 
42
25
  // Get Sform context for form state and actions
43
26
  const sformContext = getSformContext();
@@ -45,13 +28,17 @@
45
28
  // Get form state from context with the generic type
46
29
  const formState = $derived.by(sformContext.getFormState<T>);
47
30
 
48
- const isDisabled = $derived(disabled || formState.pending);
31
+ const isDisabled = $derived(
32
+ disabled || formState.pending || !hasForm || sformContext.disabled
33
+ );
49
34
 
50
35
  async function handleClick(event: MouseEvent) {
51
36
  if (buttonType !== 'submit') return;
52
37
 
53
38
  event.preventDefault();
54
39
 
40
+ if (sformContext.disabled) return;
41
+
55
42
  // Focus the button to trigger blur on any focused input before submission
56
43
  // This ensures blur validation runs with valid form data, not stale data
57
44
  buttonElement.focus();
@@ -62,12 +49,15 @@
62
49
  await onsubmit();
63
50
  }
64
51
 
52
+ await sformContext.runLifecycleHooks('beforeSubmit');
53
+
65
54
  // Mark form as submitted and all fields dirty so issues display when server responds
66
55
  sformContext.markSubmitted();
67
56
  sformContext.markAllFieldsDirty();
68
57
 
69
58
  // Submit the form via context
70
59
  sformContext.submitForm();
60
+ await sformContext.runLifecycleHooks('afterSubmitTriggered');
71
61
  }
72
62
  let buttonElement: HTMLButtonElement;
73
63
  </script>
@@ -79,7 +69,9 @@
79
69
  disabled={isDisabled}
80
70
  onclick={handleClick}
81
71
  >
82
- {#if typeof label === 'function'}
72
+ {#if children}
73
+ {@render children(formState)}
74
+ {:else if typeof label === 'function'}
83
75
  {@render label(formState)}
84
76
  {:else}
85
77
  {label}
@@ -1,27 +1,6 @@
1
- import { type Snippet } from 'svelte';
2
- import type { ButtonState, RemoteFormIssue } from '../types.js';
1
+ import type { ButtonInputProps } from '../types.js';
3
2
  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 | Snippet<[ButtonState<T>]>;
16
- /** Button type */
17
- buttonType?: "submit" | "reset" | "button";
18
- /** Button class */
19
- class?: string;
20
- /** Whether button is disabled */
21
- disabled?: boolean;
22
- /** Callback that runs before validation/submission (can be async) */
23
- onsubmit?: () => void | Promise<void>;
24
- };
3
+ props: ButtonInputProps<T>;
25
4
  exports: {};
26
5
  bindings: "";
27
6
  slots: {};
@@ -1,10 +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} />
1
+ <script lang="ts">
2
+ import type { HiddenInputProps } from '../types.js';
3
+
4
+ let { field, name, value, disabled }: 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} {disabled} />
@@ -185,7 +185,7 @@
185
185
 
186
186
  <TopLabel {label} {labelClass} {name} />
187
187
  <!-- Hidden input holds the actual value for form submission -->
188
- <input type="hidden" {name} value={field.value() ?? ''} />
188
+ <input type="hidden" {name} value={field.value() ?? ''} {disabled} />
189
189
  <!-- Visible input shows masked display value but doesn't submit (no name) -->
190
190
  <div class="sform-input-wrapper {wrapperClass ?? ''}">
191
191
  {#if prefix}
@@ -417,47 +417,6 @@ select.sform-input {
417
417
  user-select: none;
418
418
  }
419
419
 
420
- /* ===========================================
421
- Toggle Options (Segmented Control)
422
- =========================================== */
423
- .sform-toggle-options {
424
- display: inline-flex;
425
- background: var(--sform-bg-muted);
426
- border-radius: var(--sform-radius-lg);
427
- padding: 0.25rem;
428
- gap: 0.25rem;
429
- }
430
-
431
- .sform-toggle-option {
432
- padding: var(--sform-padding-sm);
433
- font-size: var(--sform-font-size-sm);
434
- font-weight: 500;
435
- color: var(--sform-text-muted);
436
- background: transparent;
437
- border: none;
438
- border-radius: var(--sform-radius);
439
- cursor: pointer;
440
- transition:
441
- background-color var(--sform-transition),
442
- color var(--sform-transition);
443
- }
444
-
445
- .sform-toggle-option:hover:not(:disabled):not(.selected) {
446
- color: var(--sform-text);
447
- background: rgba(0, 0, 0, 0.05);
448
- }
449
-
450
- .sform-toggle-option.selected {
451
- color: var(--sform-primary);
452
- background: var(--sform-bg);
453
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
454
- }
455
-
456
- .sform-toggle-option:disabled {
457
- opacity: 0.5;
458
- cursor: not-allowed;
459
- }
460
-
461
420
  /* ===========================================
462
421
  Password Input with Toggle
463
422
  =========================================== */
@@ -18,6 +18,39 @@ export type { RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFor
18
18
  * - 'submit': Validate and show all issues only after submit
19
19
  */
20
20
  export type ValidateOn = 'blur' | 'change' | 'submit';
21
+ /**
22
+ * Lifecycle event names emitted by Sform.
23
+ */
24
+ export type SformLifecycleEvent = 'beforeSubmit' | 'afterSubmitTriggered' | 'afterSubmitResponse' | 'beforeValidate' | 'afterValidateCalled' | 'afterValidateSettled';
25
+ /**
26
+ * Function signature for lifecycle hooks.
27
+ */
28
+ export type SformLifecycleHook = () => void | Promise<void>;
29
+ /**
30
+ * Lifecycle hooks that can be registered by Sform and Sfield components.
31
+ */
32
+ export interface SformLifecycleHooks {
33
+ /** Runs before submit is triggered by Sbutton. */
34
+ beforeSubmit?: SformLifecycleHook;
35
+ /** Runs right after submit is triggered by Sbutton. */
36
+ afterSubmitTriggered?: SformLifecycleHook;
37
+ /** Runs after the submit cycle receives a response (pending -> idle). */
38
+ afterSubmitResponse?: SformLifecycleHook;
39
+ /** Runs immediately before form.validate() is called. */
40
+ beforeValidate?: SformLifecycleHook;
41
+ /** Runs immediately after form.validate() is called. */
42
+ afterValidateCalled?: SformLifecycleHook;
43
+ /** Runs when form.validate() settles (resolves or rejects). */
44
+ afterValidateSettled?: SformLifecycleHook;
45
+ }
46
+ /**
47
+ * Where a field's issues should be displayed.
48
+ * - auto: visible fields display their own issues; hidden fields leave issues to SIssues
49
+ * - field: this field displays its own issues
50
+ * - form: this field leaves issues to SIssues
51
+ * - none: this field marks issues as handled without rendering them
52
+ */
53
+ export type IssueDisplay = 'auto' | 'field' | 'form' | 'none';
21
54
  /**
22
55
  * Field state tracking
23
56
  */
@@ -72,7 +105,6 @@ export interface SfieldTypeMap {
72
105
  toggle: boolean;
73
106
  select: string;
74
107
  radio: string;
75
- 'toggle-options': string;
76
108
  'checkbox-group': string[];
77
109
  file: File;
78
110
  }
@@ -86,7 +118,7 @@ export type AllowedSfieldType<T> = {
86
118
  /**
87
119
  * Supported input types for Sfield
88
120
  */
89
- export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'textarea' | 'select' | 'checkbox' | 'checkbox-group' | 'radio' | 'file' | 'hidden' | 'range' | 'toggle' | 'toggle-options' | 'masked';
121
+ export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'textarea' | 'select' | 'checkbox' | 'checkbox-group' | 'radio' | 'file' | 'hidden' | 'range' | 'toggle' | 'masked';
90
122
  /** Text-like input types */
91
123
  export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'file';
92
124
  /** Numeric input types */
@@ -158,9 +190,11 @@ export interface SformContext {
158
190
  /** Form-level validateOn mode */
159
191
  validateOn: ValidateOn;
160
192
  /** Trigger validation (called on blur/input based on mode) */
161
- triggerValidation: () => void;
193
+ triggerValidation: () => void | Promise<void>;
162
194
  /** Whether form has been submitted */
163
195
  submitted: boolean;
196
+ /** Whether the whole form is disabled */
197
+ disabled: boolean;
164
198
  /** Mark form as submitted */
165
199
  markSubmitted: () => void;
166
200
  /** Mark all fields as touched and dirty to show all issues */
@@ -173,6 +207,10 @@ export interface SformContext {
173
207
  registerFieldWithIssueDisplay: (name: string) => void;
174
208
  /** Programmatically submit the form */
175
209
  submitForm: () => void;
210
+ /** Register lifecycle hooks. Returns an unregister callback. */
211
+ registerLifecycleHooks: (hooks: SformLifecycleHooks) => () => void;
212
+ /** Run all hooks for a lifecycle event. */
213
+ runLifecycleHooks: (event: SformLifecycleEvent) => Promise<void>;
176
214
  /** Get the current form state (for buttons and state-aware components) */
177
215
  getFormState: <T = unknown>() => ButtonState<T>;
178
216
  /** Get issues that are not displayed by any Sfield component */
@@ -211,6 +249,14 @@ export interface SformProps<Input extends RemoteFormInput = RemoteFormInput, Out
211
249
  validateOn?: ValidateOn;
212
250
  /** Form element class */
213
251
  class?: string;
252
+ /** If true, only run preflight validation (no submission) */
253
+ preflightOnly?: boolean;
254
+ /** If true, reset touched/dirty/submitted state after successful submit response */
255
+ resetOnSuccess?: boolean;
256
+ /** Lifecycle hooks for submit/validate phases */
257
+ lifecycle?: SformLifecycleHooks;
258
+ /** If true, disables the entire form: no validation, no submission, all fields disabled */
259
+ disabled?: boolean;
214
260
  /** Children content */
215
261
  children: Snippet;
216
262
  }
@@ -327,16 +373,6 @@ export interface ToggleSfieldProps extends BaseSfieldProps {
327
373
  /** Value when unchecked */
328
374
  uncheckedValue?: string;
329
375
  }
330
- /**
331
- * Props for toggle-options input (button group)
332
- */
333
- export interface ToggleOptionsSfieldProps extends BaseSfieldProps {
334
- type: 'toggle-options';
335
- /** Options for toggle buttons */
336
- options: ToggleOption[] | string[];
337
- /** Allow multiple selections */
338
- multiple?: boolean;
339
- }
340
376
  /**
341
377
  * Props for masked input
342
378
  */
@@ -354,7 +390,7 @@ export interface MaskedSfieldProps extends BaseSfieldProps {
354
390
  /**
355
391
  * Discriminated union of all Sfield prop types
356
392
  */
357
- export type SfieldProps = TextSfieldProps | PasswordSfieldProps | NumberSfieldProps | TextareaSfieldProps | SelectSfieldProps | CheckboxSfieldProps | CheckboxGroupSfieldProps | RadioSfieldProps | RangeSfieldProps | ToggleSfieldProps | ToggleOptionsSfieldProps | MaskedSfieldProps;
393
+ export type SfieldProps = TextSfieldProps | PasswordSfieldProps | NumberSfieldProps | TextareaSfieldProps | SelectSfieldProps | CheckboxSfieldProps | CheckboxGroupSfieldProps | RadioSfieldProps | RangeSfieldProps | ToggleSfieldProps | MaskedSfieldProps;
358
394
  /**
359
395
  * Props that Sfield manages internally and should NOT be passed from parent.
360
396
  * These are set by Sfield itself based on context and internal state.
@@ -372,10 +408,14 @@ interface SfieldExtraProps<T extends RemoteFormFieldValue> {
372
408
  field: RemoteFormField<T>;
373
409
  /** Field-level validateOn override */
374
410
  validateOn?: ValidateOn;
411
+ /** Where this field's issues should be displayed */
412
+ issueDisplay?: IssueDisplay;
375
413
  /** CSS classes for sub-elements (wrapper, label, input, messages) */
376
414
  class?: SfieldClasses | string;
377
415
  /** Hint text displayed below the input, above validation messages */
378
416
  hint?: string | Snippet;
417
+ /** Lifecycle hooks registered while this field is mounted */
418
+ lifecycle?: SformLifecycleHooks;
379
419
  }
380
420
  /**
381
421
  * Helper type: Create Sfield props from component props
@@ -404,14 +444,12 @@ export type SfieldRadioProps = SfieldPropsFrom<RadioInputProps, string, 'radio'>
404
444
  export type SfieldRangeProps = SfieldPropsFrom<RangeInputProps, number, 'range'>;
405
445
  /** Props for toggle input */
406
446
  export type SfieldToggleProps = SfieldPropsFrom<ToggleInputProps, boolean, 'toggle'>;
407
- /** Props for toggle-options input */
408
- export type SfieldToggleOptionsProps = SfieldPropsFrom<ToggleOptionsInputProps, string, 'toggle-options'>;
409
447
  /** Props for masked input */
410
448
  export type SfieldMaskedProps = SfieldPropsFrom<MaskedInputProps, string, 'masked'>;
411
449
  /** Props for hidden input */
412
450
  export type SfieldHiddenProps = SfieldPropsFrom<HiddenInputProps, string, 'hidden'>;
413
451
  /** All possible Sfield props as a discriminated union */
414
- type AllSfieldProps = SfieldTextProps | SfieldPasswordProps | SfieldNumberProps | SfieldTextareaProps | SfieldSelectProps | SfieldCheckboxProps | SfieldCheckboxGroupProps | SfieldRadioProps | SfieldRangeProps | SfieldToggleProps | SfieldToggleOptionsProps | SfieldMaskedProps | SfieldHiddenProps;
452
+ type AllSfieldProps = SfieldTextProps | SfieldPasswordProps | SfieldNumberProps | SfieldTextareaProps | SfieldSelectProps | SfieldCheckboxProps | SfieldCheckboxGroupProps | SfieldRadioProps | SfieldRangeProps | SfieldToggleProps | SfieldMaskedProps | SfieldHiddenProps;
415
453
  /**
416
454
  * Extract props that match a specific field value type.
417
455
  * This ensures type="number" is only valid for number fields, etc.
@@ -575,6 +613,8 @@ export interface HiddenInputProps {
575
613
  name: string;
576
614
  /** The value for the hidden field - required for hidden inputs */
577
615
  value?: string;
616
+ /** Whether the hidden input is disabled */
617
+ disabled?: boolean;
578
618
  }
579
619
  /**
580
620
  * @deprecated Use ButtonState instead
@@ -608,8 +648,8 @@ export interface ButtonFormLike<T = unknown> {
608
648
  export interface ButtonInputProps<T = unknown> {
609
649
  /** The remote form - used to infer the result type T */
610
650
  form: ButtonFormLike<T>;
611
- /** Button text (used if no children snippet provided) */
612
- label?: string | Snippet;
651
+ /** Button text (used if no children snippet provided). Can also be a snippet receiving ButtonState<T>. */
652
+ label?: string | Snippet<[ButtonState<T>]>;
613
653
  /** Button type */
614
654
  buttonType?: 'submit' | 'reset' | 'button';
615
655
  /** Button class */
@@ -666,23 +706,6 @@ export interface ToggleInputProps extends BaseInputComponentProps {
666
706
  /** Value when unchecked */
667
707
  uncheckedValue?: string;
668
708
  }
669
- /**
670
- * Toggle option for ToggleOptions component
671
- */
672
- export interface ToggleOption {
673
- value: string;
674
- label: string | Snippet;
675
- disabled?: boolean;
676
- }
677
- /**
678
- * Props for toggle options (button group) component
679
- */
680
- export interface ToggleOptionsInputProps extends BaseInputComponentProps {
681
- /** Options to display as toggle buttons */
682
- options: ToggleOption[] | string[];
683
- /** Allow multiple selections */
684
- multiple?: boolean;
685
- }
686
709
  /**
687
710
  * Props for masked input component
688
711
  */
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { Sform, Sfield, Sbutton, SIssues, SResult } from './Sform/index.js';
2
- export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonState, ButtonInputProps, InputAffixProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './Sform/index.js';
2
+ export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonState, ButtonInputProps, InputAffixProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './Sform/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samuel-charpentier/sform",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A Svelte 5 form library for SvelteKit remote forms",
5
5
  "keywords": [
6
6
  "svelte",