@samuel-charpentier/sform 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/dist/Sform/Sfield.svelte +164 -0
- package/dist/Sform/Sfield.svelte.d.ts +4 -0
- package/dist/Sform/Sform.svelte +101 -0
- package/dist/Sform/Sform.svelte.d.ts +51 -0
- package/dist/Sform/context.svelte.d.ts +3 -0
- package/dist/Sform/context.svelte.js +65 -0
- package/dist/Sform/index.d.ts +5 -0
- package/dist/Sform/index.js +7 -0
- package/dist/Sform/inputs/ButtonInput.svelte +75 -0
- package/dist/Sform/inputs/ButtonInput.svelte.d.ts +32 -0
- package/dist/Sform/inputs/CheckboxGroupInput.svelte +81 -0
- package/dist/Sform/inputs/CheckboxGroupInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/CheckboxInput.svelte +47 -0
- package/dist/Sform/inputs/CheckboxInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/MaskedInput.svelte +220 -0
- package/dist/Sform/inputs/MaskedInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/NumberInput.svelte +116 -0
- package/dist/Sform/inputs/NumberInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/PasswordInput.svelte +142 -0
- package/dist/Sform/inputs/PasswordInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/RadioInput.svelte +81 -0
- package/dist/Sform/inputs/RadioInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/RangeInput.svelte +67 -0
- package/dist/Sform/inputs/RangeInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/SelectInput.svelte +36 -0
- package/dist/Sform/inputs/SelectInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/TextInput.svelte +61 -0
- package/dist/Sform/inputs/TextInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/TextareaInput.svelte +56 -0
- package/dist/Sform/inputs/TextareaInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/ToggleInput.svelte +101 -0
- package/dist/Sform/inputs/ToggleInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/ToggleOptionsInput.svelte +118 -0
- package/dist/Sform/inputs/ToggleOptionsInput.svelte.d.ts +4 -0
- package/dist/Sform/sform.css +628 -0
- package/dist/Sform/types.d.ts +610 -0
- package/dist/Sform/types.js +5 -0
- package/dist/Sform/utils/mask.d.ts +68 -0
- package/dist/Sform/utils/mask.js +155 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RemoteFormInstance, ValidateOn, EnhanceCallback } from './types.js';
|
|
2
|
+
import type { RemoteFormFields } from '@sveltejs/kit';
|
|
3
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
declare function $$render<Input extends import('@sveltejs/kit').RemoteFormInput, Output>(): {
|
|
6
|
+
props: {
|
|
7
|
+
/** Remote form object from form() API, or the result of form.for(id) */
|
|
8
|
+
form: RemoteFormInstance<Input, Output>;
|
|
9
|
+
/** Preflight validation schema (Valibot, Zod, or any StandardSchema) */
|
|
10
|
+
schema?: StandardSchemaV1<Input, unknown>;
|
|
11
|
+
/** Enhance callback for custom form submission handling */
|
|
12
|
+
enhance?: EnhanceCallback<Input>;
|
|
13
|
+
/** When to validate and show issues: 'blur' (default), 'change', or 'submit' */
|
|
14
|
+
validateOn?: ValidateOn;
|
|
15
|
+
/** Form element class */
|
|
16
|
+
class?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Children snippet receives typed fields for type-safe field access.
|
|
19
|
+
* @example
|
|
20
|
+
* ```svelte
|
|
21
|
+
* <Sform {form}>
|
|
22
|
+
* {#snippet children(fields)}
|
|
23
|
+
* <Sfield field={fields.username} type="text" />
|
|
24
|
+
* {/snippet}
|
|
25
|
+
* </Sform>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
children: Snippet<[RemoteFormFields<Input>]>;
|
|
29
|
+
};
|
|
30
|
+
exports: {};
|
|
31
|
+
bindings: "";
|
|
32
|
+
slots: {};
|
|
33
|
+
events: {};
|
|
34
|
+
};
|
|
35
|
+
declare class __sveltets_Render<Input extends import('@sveltejs/kit').RemoteFormInput, Output> {
|
|
36
|
+
props(): ReturnType<typeof $$render<Input, Output>>['props'];
|
|
37
|
+
events(): ReturnType<typeof $$render<Input, Output>>['events'];
|
|
38
|
+
slots(): ReturnType<typeof $$render<Input, Output>>['slots'];
|
|
39
|
+
bindings(): "";
|
|
40
|
+
exports(): {};
|
|
41
|
+
}
|
|
42
|
+
interface $$IsomorphicComponent {
|
|
43
|
+
new <Input extends import('@sveltejs/kit').RemoteFormInput, Output>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Input, Output>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Input, Output>['props']>, ReturnType<__sveltets_Render<Input, Output>['events']>, ReturnType<__sveltets_Render<Input, Output>['slots']>> & {
|
|
44
|
+
$$bindings?: ReturnType<__sveltets_Render<Input, Output>['bindings']>;
|
|
45
|
+
} & ReturnType<__sveltets_Render<Input, Output>['exports']>;
|
|
46
|
+
<Input extends import('@sveltejs/kit').RemoteFormInput, Output>(internal: unknown, props: ReturnType<__sveltets_Render<Input, Output>['props']> & {}): ReturnType<__sveltets_Render<Input, Output>['exports']>;
|
|
47
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
|
|
48
|
+
}
|
|
49
|
+
declare const Sform: $$IsomorphicComponent;
|
|
50
|
+
type Sform<Input extends import('@sveltejs/kit').RemoteFormInput, Output> = InstanceType<typeof Sform<Input, Output>>;
|
|
51
|
+
export default Sform;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
const SFORM_CONTEXT_KEY = Symbol('sform-context');
|
|
4
|
+
export function createSformContext(getValidateOn, getFieldNames, triggerValidation) {
|
|
5
|
+
const touched = new SvelteSet();
|
|
6
|
+
const dirty = new SvelteSet();
|
|
7
|
+
let submitted = $state(false);
|
|
8
|
+
const context = {
|
|
9
|
+
get validateOn() {
|
|
10
|
+
return getValidateOn();
|
|
11
|
+
},
|
|
12
|
+
get submitted() {
|
|
13
|
+
return submitted;
|
|
14
|
+
},
|
|
15
|
+
triggerValidation,
|
|
16
|
+
getFieldState: (name) => ({
|
|
17
|
+
touched: touched.has(name),
|
|
18
|
+
dirty: dirty.has(name)
|
|
19
|
+
}),
|
|
20
|
+
markTouched: (name) => {
|
|
21
|
+
touched.add(name);
|
|
22
|
+
},
|
|
23
|
+
markDirty: (name) => {
|
|
24
|
+
dirty.add(name);
|
|
25
|
+
},
|
|
26
|
+
shouldDisplayIssues: (name, fieldValidateOn) => {
|
|
27
|
+
const effectiveValidateOn = fieldValidateOn ?? getValidateOn();
|
|
28
|
+
switch (effectiveValidateOn) {
|
|
29
|
+
case 'blur':
|
|
30
|
+
return touched.has(name);
|
|
31
|
+
case 'change':
|
|
32
|
+
return dirty.has(name);
|
|
33
|
+
case 'submit':
|
|
34
|
+
return submitted;
|
|
35
|
+
default:
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
markSubmitted: () => {
|
|
40
|
+
submitted = true;
|
|
41
|
+
},
|
|
42
|
+
markAllFieldsDirty: () => {
|
|
43
|
+
// Get all field names from the form and mark them as touched and dirty
|
|
44
|
+
const fieldNames = getFieldNames();
|
|
45
|
+
for (const name of fieldNames) {
|
|
46
|
+
touched.add(name);
|
|
47
|
+
dirty.add(name);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
resetFieldStates: () => {
|
|
51
|
+
touched.clear();
|
|
52
|
+
dirty.clear();
|
|
53
|
+
submitted = false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
setContext(SFORM_CONTEXT_KEY, context);
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
export function getSformContext() {
|
|
60
|
+
const context = getContext(SFORM_CONTEXT_KEY);
|
|
61
|
+
if (!context) {
|
|
62
|
+
throw new Error('Sfield must be used within an Sform component');
|
|
63
|
+
}
|
|
64
|
+
return context;
|
|
65
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as Sform } from './Sform.svelte';
|
|
2
|
+
export { default as Sfield } from './Sfield.svelte';
|
|
3
|
+
export { default as Sbutton } from './inputs/ButtonInput.svelte';
|
|
4
|
+
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';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Main components
|
|
2
|
+
export { default as Sform } from './Sform.svelte';
|
|
3
|
+
export { default as Sfield } from './Sfield.svelte';
|
|
4
|
+
// Standalone components (not routed through Sfield)
|
|
5
|
+
export { default as Sbutton } from './inputs/ButtonInput.svelte';
|
|
6
|
+
// Utilities
|
|
7
|
+
export { applyMask, unmask, MASK_PATTERNS, DEFAULT_TOKENS } from './utils/mask.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { ButtonFormState, RemoteFormIssue } from '../types.js';
|
|
4
|
+
|
|
5
|
+
interface FormLike {
|
|
6
|
+
pending?: number;
|
|
7
|
+
result?: unknown;
|
|
8
|
+
fields: {
|
|
9
|
+
allIssues?: () => RemoteFormIssue[] | undefined;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** The remote form - required for button state */
|
|
15
|
+
form: FormLike;
|
|
16
|
+
/** Button text (used if no snippets provided) */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Button type */
|
|
19
|
+
buttonType?: 'submit' | 'reset' | 'button';
|
|
20
|
+
/** Button class */
|
|
21
|
+
class?: string;
|
|
22
|
+
/** Whether button is disabled */
|
|
23
|
+
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]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
form,
|
|
36
|
+
label = 'Submit',
|
|
37
|
+
buttonType = 'submit',
|
|
38
|
+
class: className,
|
|
39
|
+
disabled = false,
|
|
40
|
+
defaultState,
|
|
41
|
+
pendingState,
|
|
42
|
+
successState,
|
|
43
|
+
errorState
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
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;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
pending,
|
|
54
|
+
success: hasResult && !hasIssues && !pending,
|
|
55
|
+
hasIssues,
|
|
56
|
+
result: form.result
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const isDisabled = $derived(disabled || formState.pending);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
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)}
|
|
72
|
+
{:else}
|
|
73
|
+
{label}
|
|
74
|
+
{/if}
|
|
75
|
+
</button>
|
|
@@ -0,0 +1,32 @@
|
|
|
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;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
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]>;
|
|
29
|
+
}
|
|
30
|
+
declare const ButtonInput: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type ButtonInput = ReturnType<typeof ButtonInput>;
|
|
32
|
+
export default ButtonInput;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckboxGroupInputProps, SelectOption } from '../types.js';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
field,
|
|
6
|
+
name,
|
|
7
|
+
label,
|
|
8
|
+
class: className,
|
|
9
|
+
labelClass,
|
|
10
|
+
disabled,
|
|
11
|
+
options = [],
|
|
12
|
+
showIssues,
|
|
13
|
+
onblur,
|
|
14
|
+
oninput
|
|
15
|
+
}: CheckboxGroupInputProps = $props();
|
|
16
|
+
|
|
17
|
+
// Normalize options to SelectOption format
|
|
18
|
+
const normalizedOptions = $derived(
|
|
19
|
+
options.map((opt): SelectOption => (typeof opt === 'string' ? { value: opt, label: opt } : opt))
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Helper to get field attrs with controlled aria-invalid
|
|
23
|
+
function getFieldAttrs(optionValue: string) {
|
|
24
|
+
const attrs = field.as('checkbox', optionValue);
|
|
25
|
+
return {
|
|
26
|
+
...attrs,
|
|
27
|
+
'aria-invalid': showIssues ? attrs['aria-invalid'] : undefined
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<fieldset class="sform-checkbox-group {className ?? ''}" {disabled}>
|
|
33
|
+
{#if label}
|
|
34
|
+
<legend class={labelClass}>{label}</legend>
|
|
35
|
+
{/if}
|
|
36
|
+
{#each normalizedOptions as option}
|
|
37
|
+
{@const fieldAttrs = getFieldAttrs(option.value)}
|
|
38
|
+
{@const uniqueId = `${name}-${option.value}`}
|
|
39
|
+
<label class="sform-checkbox-option" class:disabled={option.disabled}>
|
|
40
|
+
<input
|
|
41
|
+
{...fieldAttrs}
|
|
42
|
+
type="checkbox"
|
|
43
|
+
id={uniqueId}
|
|
44
|
+
disabled={disabled || option.disabled}
|
|
45
|
+
{onblur}
|
|
46
|
+
{oninput}
|
|
47
|
+
/>
|
|
48
|
+
<span class="sform-checkbox-label">{option.label}</span>
|
|
49
|
+
</label>
|
|
50
|
+
{/each}
|
|
51
|
+
</fieldset>
|
|
52
|
+
|
|
53
|
+
<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
|
+
.sform-checkbox-option {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 0.5rem;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
padding: 0.25rem 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.sform-checkbox-option.disabled {
|
|
74
|
+
opacity: 0.6;
|
|
75
|
+
cursor: not-allowed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sform-checkbox-option input {
|
|
79
|
+
margin: 0;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckboxRadioInputProps } from '../types.js';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
field,
|
|
6
|
+
name,
|
|
7
|
+
label,
|
|
8
|
+
class: className,
|
|
9
|
+
labelClass,
|
|
10
|
+
disabled,
|
|
11
|
+
value,
|
|
12
|
+
showIssues,
|
|
13
|
+
onblur,
|
|
14
|
+
oninput
|
|
15
|
+
}: CheckboxRadioInputProps = $props();
|
|
16
|
+
|
|
17
|
+
// Single boolean checkbox - no value needed
|
|
18
|
+
const fieldAttrs = $derived({
|
|
19
|
+
...field.as('checkbox'),
|
|
20
|
+
'aria-invalid': showIssues ? field.as('checkbox')['aria-invalid'] : undefined
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<label class="sform-checkbox {className ?? ''}" class:disabled>
|
|
25
|
+
<input {...fieldAttrs} type="checkbox" id={name} {disabled} {onblur} {oninput} />
|
|
26
|
+
{#if label}
|
|
27
|
+
<span class={labelClass}>{label}</span>
|
|
28
|
+
{/if}
|
|
29
|
+
</label>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.sform-checkbox {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.sform-checkbox.disabled {
|
|
40
|
+
opacity: 0.6;
|
|
41
|
+
cursor: not-allowed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sform-checkbox input {
|
|
45
|
+
margin: 0;
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { MaskedInputProps } from '../types.js';
|
|
3
|
+
import { applyMask, MASK_PATTERNS, type MaskPattern, type MaskToken } from '../utils/mask.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
field,
|
|
7
|
+
name,
|
|
8
|
+
label,
|
|
9
|
+
placeholder,
|
|
10
|
+
class: className,
|
|
11
|
+
labelClass,
|
|
12
|
+
disabled,
|
|
13
|
+
readonly,
|
|
14
|
+
autocomplete,
|
|
15
|
+
mask,
|
|
16
|
+
tokens,
|
|
17
|
+
maskPlaceholder = '_',
|
|
18
|
+
showMaskPlaceholder = false,
|
|
19
|
+
unmaskValue = true,
|
|
20
|
+
showIssues,
|
|
21
|
+
prefix,
|
|
22
|
+
suffix,
|
|
23
|
+
wrapperClass,
|
|
24
|
+
onblur,
|
|
25
|
+
oninput
|
|
26
|
+
}: MaskedInputProps = $props();
|
|
27
|
+
|
|
28
|
+
// Resolve mask pattern
|
|
29
|
+
const resolvedMask = $derived(mask in MASK_PATTERNS ? MASK_PATTERNS[mask as MaskPattern] : mask);
|
|
30
|
+
|
|
31
|
+
// Get aria-invalid from field, respecting showIssues
|
|
32
|
+
const ariaInvalid = $derived(showIssues ? field.as('text')['aria-invalid'] : undefined);
|
|
33
|
+
|
|
34
|
+
let inputElement: HTMLInputElement | undefined = $state();
|
|
35
|
+
|
|
36
|
+
// Local display value state - synced with field
|
|
37
|
+
let displayValue = $state('');
|
|
38
|
+
let maskComplete = $state(false);
|
|
39
|
+
|
|
40
|
+
// Track the last value WE stored to distinguish our updates from external changes
|
|
41
|
+
let lastStoredValue: string | undefined = undefined;
|
|
42
|
+
|
|
43
|
+
// Initialize from field value - only for EXTERNAL changes (initial load, programmatic updates)
|
|
44
|
+
$effect(() => {
|
|
45
|
+
const fieldVal = field.value();
|
|
46
|
+
const val = typeof fieldVal === 'string' ? fieldVal : '';
|
|
47
|
+
|
|
48
|
+
// Only sync from field if this is NOT a value we just stored
|
|
49
|
+
// This prevents the $effect from fighting with user input
|
|
50
|
+
if (val === lastStoredValue) {
|
|
51
|
+
// This is our own update echoing back, ignore it
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (val) {
|
|
56
|
+
const result = applyMask(val, {
|
|
57
|
+
mask: resolvedMask,
|
|
58
|
+
tokens,
|
|
59
|
+
placeholder: maskPlaceholder,
|
|
60
|
+
showPlaceholder: showMaskPlaceholder
|
|
61
|
+
});
|
|
62
|
+
displayValue = result.masked;
|
|
63
|
+
maskComplete = result.complete;
|
|
64
|
+
// Update lastStoredValue to match what we're displaying
|
|
65
|
+
lastStoredValue = unmaskValue ? result.raw : result.masked;
|
|
66
|
+
} else {
|
|
67
|
+
displayValue = '';
|
|
68
|
+
maskComplete = false;
|
|
69
|
+
lastStoredValue = '';
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function processInput(
|
|
74
|
+
inputValue: string,
|
|
75
|
+
cursorPos: number
|
|
76
|
+
): { masked: string; raw: string; cursorPosition: number; complete: boolean } {
|
|
77
|
+
const result = applyMask(
|
|
78
|
+
inputValue,
|
|
79
|
+
{
|
|
80
|
+
mask: resolvedMask,
|
|
81
|
+
tokens,
|
|
82
|
+
placeholder: maskPlaceholder,
|
|
83
|
+
showPlaceholder: showMaskPlaceholder
|
|
84
|
+
},
|
|
85
|
+
cursorPos
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleInput(event: Event) {
|
|
92
|
+
const input = event.target as HTMLInputElement;
|
|
93
|
+
const cursorPos = input.selectionStart ?? 0;
|
|
94
|
+
|
|
95
|
+
const result = processInput(input.value, cursorPos);
|
|
96
|
+
|
|
97
|
+
// Update local state immediately
|
|
98
|
+
displayValue = result.masked;
|
|
99
|
+
maskComplete = result.complete;
|
|
100
|
+
|
|
101
|
+
// Store in field and track what we stored
|
|
102
|
+
const valueToStore = unmaskValue ? result.raw : result.masked;
|
|
103
|
+
lastStoredValue = valueToStore;
|
|
104
|
+
field.set(valueToStore);
|
|
105
|
+
|
|
106
|
+
// Restore cursor position after DOM update
|
|
107
|
+
requestAnimationFrame(() => {
|
|
108
|
+
if (inputElement) {
|
|
109
|
+
inputElement.setSelectionRange(result.cursorPosition, result.cursorPosition);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
oninput?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handlePaste(event: ClipboardEvent) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const input = event.target as HTMLInputElement;
|
|
119
|
+
const pastedText = event.clipboardData?.getData('text') ?? '';
|
|
120
|
+
const selStart = input.selectionStart ?? 0;
|
|
121
|
+
const selEnd = input.selectionEnd ?? 0;
|
|
122
|
+
|
|
123
|
+
// Build new value with pasted content
|
|
124
|
+
const before = displayValue.slice(0, selStart);
|
|
125
|
+
const after = displayValue.slice(selEnd);
|
|
126
|
+
const newValue = before + pastedText + after;
|
|
127
|
+
|
|
128
|
+
const result = processInput(newValue, selStart + pastedText.length);
|
|
129
|
+
|
|
130
|
+
displayValue = result.masked;
|
|
131
|
+
maskComplete = result.complete;
|
|
132
|
+
|
|
133
|
+
// Store and track the value
|
|
134
|
+
const valueToStore = unmaskValue ? result.raw : result.masked;
|
|
135
|
+
lastStoredValue = valueToStore;
|
|
136
|
+
field.set(valueToStore);
|
|
137
|
+
|
|
138
|
+
requestAnimationFrame(() => {
|
|
139
|
+
if (inputElement) {
|
|
140
|
+
inputElement.setSelectionRange(result.cursorPosition, result.cursorPosition);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
oninput?.();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
148
|
+
// Handle backspace specially to skip over literals
|
|
149
|
+
if (event.key === 'Backspace') {
|
|
150
|
+
const input = event.target as HTMLInputElement;
|
|
151
|
+
const cursorPos = input.selectionStart ?? 0;
|
|
152
|
+
|
|
153
|
+
if (cursorPos > 0 && input.selectionStart === input.selectionEnd) {
|
|
154
|
+
const charBefore = displayValue[cursorPos - 1];
|
|
155
|
+
const maskChar = resolvedMask[cursorPos - 1];
|
|
156
|
+
|
|
157
|
+
// If the character before cursor is a literal, skip it
|
|
158
|
+
if (charBefore === maskChar && !/[9aA*]/.test(maskChar)) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
input.setSelectionRange(cursorPos - 1, cursorPos - 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle autocomplete/autofill by watching for changes
|
|
167
|
+
function handleChange(event: Event) {
|
|
168
|
+
const input = event.target as HTMLInputElement;
|
|
169
|
+
|
|
170
|
+
// Process the entire autofilled value
|
|
171
|
+
const result = processInput(input.value, input.value.length);
|
|
172
|
+
|
|
173
|
+
displayValue = result.masked;
|
|
174
|
+
maskComplete = result.complete;
|
|
175
|
+
|
|
176
|
+
// Store and track the value
|
|
177
|
+
const valueToStore = unmaskValue ? result.raw : result.masked;
|
|
178
|
+
lastStoredValue = valueToStore;
|
|
179
|
+
field.set(valueToStore);
|
|
180
|
+
|
|
181
|
+
oninput?.();
|
|
182
|
+
}
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
{#if label}
|
|
186
|
+
<label class={labelClass} for={name}>{label}</label>
|
|
187
|
+
{/if}
|
|
188
|
+
<!-- Hidden input holds the actual value for form submission -->
|
|
189
|
+
<input type="hidden" {name} value={field.value() ?? ''} />
|
|
190
|
+
<!-- Visible input shows masked display value but doesn't submit (no name) -->
|
|
191
|
+
<div class="sform-input-wrapper {wrapperClass ?? ''}">
|
|
192
|
+
{#if prefix}
|
|
193
|
+
<div class="sform-prefix" onclick={() => inputElement?.focus()} role="presentation">
|
|
194
|
+
{#if typeof prefix === 'function'}{@render prefix()}{:else}{prefix}{/if}
|
|
195
|
+
</div>
|
|
196
|
+
{/if}
|
|
197
|
+
<input
|
|
198
|
+
bind:this={inputElement}
|
|
199
|
+
type="text"
|
|
200
|
+
id={name}
|
|
201
|
+
class={className}
|
|
202
|
+
{placeholder}
|
|
203
|
+
{disabled}
|
|
204
|
+
{readonly}
|
|
205
|
+
{autocomplete}
|
|
206
|
+
aria-invalid={ariaInvalid}
|
|
207
|
+
bind:value={displayValue}
|
|
208
|
+
oninput={handleInput}
|
|
209
|
+
onpaste={handlePaste}
|
|
210
|
+
onchange={handleChange}
|
|
211
|
+
onkeydown={handleKeyDown}
|
|
212
|
+
{onblur}
|
|
213
|
+
data-mask-complete={maskComplete}
|
|
214
|
+
/>
|
|
215
|
+
{#if suffix}
|
|
216
|
+
<div class="sform-suffix" onclick={() => inputElement?.focus()} role="presentation">
|
|
217
|
+
{#if typeof suffix === 'function'}{@render suffix()}{:else}{suffix}{/if}
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|