-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Description
Describe the problem
When implementing a production app, its common to develop generic form field components that pass through props into the input. These facilitate shared styling and logic, for example reveal logic. In an ideal world they support bindings well.
Maybe I'm stupid, but I've struggled a month or two to figure out the right way to do this, and I'm still not confident about my solution. One of the pain points has been handling hidden inputs, which seem to be a special case. When I tried using binding on them I kept getting errors that the value cannot be undefined even though I tried to initialize them with a value.
Describe the proposed solution
I put 'support' in the title because I don't know for sure what the solution is. I understand this is a userland problem, but I believe its common enough to warrant some guidance.
My reasoning:
- If there a simple solution, it would be nice to have an example in the docs.
- If this is too complex, then one must wonder if the API needs to be improved to support the usecase.
- If this in turn is also too complex, then some kind of warning is in order in the docs, explaining that generic wrappers are hard, and sketching out the tradeoffs involved.
Alternatives considered
@sillvva shared this approach which involves a lot of manual fiddling with types.
Below is the approach I am taking, which is probably a bit "dumb", but I wanted something I can more or less grok. Mostly it was trial an error to get something that functions acceptably, but I'm still not entirely happy with it. I deliberately all the logic I use in my actual app, because I want to show the kind of constraints I face.
// FormField.svelte
<script lang="ts">
import type { RemoteFormIssue } from '@sveltejs/kit';
import { tick, untrack } from 'svelte';
import { useForm } from '$lib/hooks/useForm.svelte';
import { useFormSection } from '$lib/hooks/useFormSection.svelte';
import type { FormFieldProps } from '$lib/types/form.types';
import { formSectionTransition } from '$lib/utils/form';
let {
label,
name = label.toLowerCase().trim().replaceAll(' ', '_'),
info,
value = $bindable(),
type = 'text',
required = false,
autofocus = false,
children,
hidden = false,
action,
deps,
...rest
}: FormFieldProps = $props();
const form = useForm();
$effect(() => {
if (form.autofocus && autofocus) {
throw new Error(
`FormField "${label}": Cannot enable autofocus on both Form and FormField. Choose one.`,
);
}
});
const field = $derived(form.remote.fields?.[name]);
const issues: RemoteFormIssue[] = $derived(field?.issues() ?? []);
const hide = $derived(hidden || !!children);
const attributes = $derived(field?.as(type) ?? { name, type });
const section = useFormSection(deps ? { state: () => ({ deps }) } : {});
// register with section for reset tracking
if (section) {
$effect(() => {
section.register(name);
return () => {
section.unregister(name);
// clear form value when field unmounts (e.g., parent {#if} becomes false)
field?.set(undefined);
};
});
}
// revealed: if in section, use section.revealed; otherwise always revealed
const revealed = $derived(section?.revealed ?? true);
// capture initial value for reset (before any reactive updates)
const initialValue = untrack(() => value);
// reset to initial value when section triggers a clear
let prevVersion = section?.version ?? 0;
$effect(() => {
if (section && section.version !== prevVersion) {
value = initialValue;
prevVersion = section.version;
}
});
let input: HTMLInputElement | null = $state(null);
// sync local value to RemoteForm when revealed
$effect(() => {
if (revealed) {
field?.set(value);
}
});
// autofocus
$effect(() => {
if ((form.autofocus || autofocus) && !hide) {
if (revealed) {
tick().then(() => {
input?.focus();
});
}
}
});
const transition = formSectionTransition;
</script>
{#if revealed}
<div class="form-field" class:hidden transition:transition>
<label for={name}>
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
<div class="input-wrapper">
{#if hide}
<input type="hidden" {name} {value} />
{:else}
<input bind:this={input} {...attributes} {...rest} bind:value />
{/if}
{@render action?.()}
</div>
{@render children?.()}
<div class="details">
{#if issues.length > 0}
{#each issues as issue, i (i)}
<p>{issue.message}</p>
{/each}
{:else if info}
<p>{info}</p>
{/if}
</div>
</div>
{/if}
<style>
/* ... */
</style>Importance
would make my life easier
Additional Information
No response