# Form

Native forms with transparent attachment forwarding and composable pending feedback.

- Package: `@mielui/svelte`
- Component version: `1.1.0`
- Depends on Mielui components: _internal/utils, button
- Shared utilities: utils.cn, utils.createContext, transition

## Install

~~~~sh
pnpm dlx @mielui/svelte add form
~~~~

## API

This reference is generated at build time from the component manifest, public `index.ts`, and documentation examples below. Changes to those source files are reflected here in the published Markdown. Standard Svelte and HTML attributes accepted by the exported prop types are supported.


### Actions

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### ErrorSummary

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| labels | FormErrorSummaryLabels \| undefined | - | No | No | Overrides the built-in text and accessible names. Every key is optional and English is the fallback. |
| issues | readonly Readonly<{ message: string; controlId?: string \| undefined; path?: readonly (string \| number)[] \| undefined; }>[] \| undefined | [] | No | No | Validation issues to summarize. |
| focusOnError | boolean \| undefined | true | No | No | Moves focus to the summary when issues appear. |
| heading | Snippet<[]> \| undefined | - | No | No | Replaces the summary heading. |
| children | Snippet<[readonly Readonly<{ message: string; controlId?: string \| undefined; path?: readonly (string \| number)[] \| undefined; }>[]]> \| undefined | - | No | No | Content rendered inside. |
| element | HTMLDivElement \| undefined | - | No | Yes | Bindable reference to the DOM element. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Root

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| element | HTMLFormElement \| null \| undefined | - | No | Yes | Bindable reference to the DOM element. |
| pending | number \| boolean \| undefined | false | No | No | Marks the form as submitting. A number is the count of pending requests. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Status

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| tone | "neutral" \| "success" \| "error" \| undefined | 'neutral' | No | No | Color that carries the meaning. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Submit

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |
| onkeydown | ((event: KeyboardEvent) => void) \| undefined | - | No | No | Called on key down, before the button handles the key itself. |
| onclick | ((event: MouseEvent) => void) \| undefined | - | No | No | Called when the button is activated. Loading and disabled buttons do not call it. |
| status | ButtonStatus \| undefined | - | No | No | Controlled visual state. Loading remains focusable and refuses activation. |
| disabled | boolean \| undefined | - | No | No | Prevents activation. A disabled link loses its destination and is skipped by Tab. |
| variant | ButtonVariant \| undefined | - | No | No | Visual style. Choose it for what the action means, not for decoration. |
| size | "sm" \| "md" \| "lg" \| "icon" \| undefined | - | No | No | Control height. `icon` is a square button for a single icon. |
| element | HTMLButtonElement \| HTMLAnchorElement \| undefined | - | No | Yes | Bindable reference to the rendered DOM element. Type is the union of the two possible element types -- narrow at the use site: |
| unstyled | boolean \| undefined | - | No | No | Skip the variant/size base classes and render with `class` alone. |
| loading | boolean \| undefined | - | No | No | Convenience alias for `status="loading"`. |
| labels | ButtonLabels \| undefined | - | No | No | Overrides the built-in text and accessible names. Every key is optional and English is the fallback. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Public types

~~~~ts
import type { Snippet } from 'svelte';
import type { HTMLAttributes, HTMLFormAttributes } from 'svelte/elements';
import type { ButtonProps } from '../button';
import Root from './form.svelte';
import Actions from './form-actions.svelte';
import ErrorSummary from './form-error-summary.svelte';
import Status from './form-status.svelte';
import Submit from './form-submit.svelte';

export type FormProps = HTMLFormAttributes & {
    /** Bindable reference to the DOM element. */
    element?: HTMLFormElement | null;
    /** Marks the form as submitting. A number is the count of pending requests. */
    pending?: boolean | number;
};
export type FormActionsProps = HTMLAttributes<HTMLDivElement>;
export type FormStatusProps = HTMLAttributes<HTMLDivElement> & {
    /** Color that carries the meaning. */
    tone?: 'neutral' | 'success' | 'error';
};
export type FormSubmitProps = Omit<Extract<ButtonProps, { href?: undefined }>, 'type' | 'href'>;

export type FormIssue = Readonly<{
    message: string;
    controlId?: string;
    path?: readonly (string | number)[];
}>;
export type FormErrorSummaryLabels = {
    /** Heading above the issues. A `heading` snippet replaces it. */
    heading?: string;
};
export type FormErrorSummaryProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
    /**
     * Overrides the built-in text and accessible names. Every key is optional and English is the
     * fallback.
     */
    labels?: FormErrorSummaryLabels;
    /** Validation issues to summarize. */
    issues?: readonly FormIssue[];
    /** Moves focus to the summary when issues appear. */
    focusOnError?: boolean;
    /** Replaces the summary heading. */
    heading?: Snippet;
    /** Content rendered inside. */
    children?: Snippet<[readonly FormIssue[]]>;
    /** Bindable reference to the DOM element. */
    element?: HTMLDivElement;
};

export { Actions, ErrorSummary, Root, Status, Submit };
~~~~

## Examples

### Hero

~~~~svelte
<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import * as Field from '@mielui/svelte/components/field';
    import * as Fieldset from '@mielui/svelte/components/fieldset';
    import * as Form from '@mielui/svelte/components/form';
    import { Input } from '@mielui/svelte/components/input';
    import { onDestroy } from 'svelte';

    let pending = $state(false);
    let timer: ReturnType<typeof setTimeout> | undefined;
    let message = $state('');

    onDestroy(() => {
        clearTimeout(timer);
    });

    function submit(event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) {
        event.preventDefault();
        if (pending) {
            return;
        }
        const data = new FormData(event.currentTarget, event.submitter);
        const name = String(data.get('displayName')).trim();
        pending = true;
        message = 'Checking profile…';
        timer = setTimeout(() => {
            pending = false;
            message = `${name} is ready to save. This preview keeps your data in the browser.`;
        }, 800);
    }
</script>

<Form.Root class="w-full max-w-md" onsubmit={submit} {pending}>
    <Fieldset.Root>
        <Fieldset.Legend>Profile details</Fieldset.Legend>
        <Field.Group>
            <Field.Root required>
                <Field.Label>Display name</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            name="displayName"
                            autocomplete="name"
                            placeholder="Sam Rivera"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Description>The name shown to your teammates.</Field.Description>
            </Field.Root>
            <Field.Root required>
                <Field.Label>Work email</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            type="email"
                            name="email"
                            autocomplete="email"
                            placeholder="sam@company.com"
                        />
                    {/snippet}
                </Field.Control>
            </Field.Root>
        </Field.Group>
    </Fieldset.Root>
    <Form.Actions>
        <Form.Submit>Save profile</Form.Submit>
        <Button type="reset" variant="ghost">Reset</Button>
    </Form.Actions>
    <Form.Status>{message}</Form.Status>
</Form.Root>
~~~~

### Remote Basic

~~~~svelte
<script lang="ts">
    import * as Field from '@mielui/svelte/components/field';
    import * as Fieldset from '@mielui/svelte/components/fieldset';
    import * as Form from '@mielui/svelte/components/form';
    import { Input } from '@mielui/svelte/components/input';
    import { toast } from '@mielui/svelte/components/toast';
    import { validateProfile } from './profile.remote';

    const uid = $props.id();
    const draft = validateProfile.for(uid);

    function errorDescription() {
        if (draft.fields.allIssues()?.length) {
            return 'Review the highlighted fields.';
        }

        return 'Your draft is still here. Try again.';
    }
</script>

<Form.Root
    class="w-full max-w-md"
    novalidate
    pending={draft.pending}
    {...draft.enhance(async (instance) => {
        const request = instance.submit().then((valid) => {
            if (!valid) {
                throw new Error('Review the highlighted fields.');
            }

            return instance.result;
        });

        toast.promise(request, {
            loading: 'Checking your draft…',
            success: (result) => `${result?.username ?? 'Your username'} is available.`,
            error: 'Could not validate your draft',
            errorDescription
        });

        await request.catch(() => undefined);
    })}
>
    <Form.ErrorSummary issues={draft.fields.allIssues()} />
    <Fieldset.Root>
        <Fieldset.Legend>Profile draft</Fieldset.Legend>
        <Fieldset.Description>
            This demo validates on the server. Nothing is stored. Try “admin” to see a server-side
            issue, or “system” for a form-level issue.
        </Fieldset.Description>
        <Field.Group>
            <Field.Root required issues={draft.fields.username.issues()}>
                <Field.Label>Username</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            {...draft.fields.username.as('text')}
                            autocomplete="username"
                            placeholder="sam-rivera"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Description>Between 3 and 40 characters.</Field.Description>
                <Field.Error aria-live="off" />
            </Field.Root>
            <Field.Root required issues={draft.fields.email.issues()}>
                <Field.Label>Email</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            {...draft.fields.email.as('email')}
                            autocomplete="email"
                            placeholder="sam@company.com"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Error aria-live="off" />
            </Field.Root>
        </Field.Group>
    </Fieldset.Root>
    <Form.Actions>
        <Form.Submit
            {...draft.fields.intent.as('submit', 'validate')}
            labels={{ loading: 'Checking' }}
        >
            Validate draft
        </Form.Submit>
    </Form.Actions>
</Form.Root>
~~~~

### Remote Profile

~~~~svelte
<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import * as Field from '@mielui/svelte/components/field';
    import * as Fieldset from '@mielui/svelte/components/fieldset';
    import * as Form from '@mielui/svelte/components/form';
    import { Input } from '@mielui/svelte/components/input';
    import { toast } from '@mielui/svelte/components/toast';
    import { validateProfile } from './profile.remote';

    const uid = $props.id();
    const draft = validateProfile.for(uid);

    function errorDescription() {
        if (draft.fields.allIssues()?.length) {
            return 'Review the highlighted fields.';
        }

        return 'Your draft is still here. Try again.';
    }
</script>

<Form.Root
    class="w-full max-w-md"
    novalidate
    pending={draft.pending}
    {...draft.enhance(async (instance) => {
        const request = instance.submit().then((valid) => {
            if (!valid) {
                throw new Error('Review the highlighted fields.');
            }
            const result = instance.result;

            if (result?.intent === 'validate-reset') {
                instance.element.reset();
            }

            return result;
        });

        toast.promise(request, {
            loading: 'Checking your draft…',
            success: (result) => `${result?.username ?? 'Your username'} is available.`,
            error: 'Could not validate your draft',
            errorDescription
        });

        await request.catch(() => undefined);
    })}
>
    <Form.ErrorSummary issues={draft.fields.allIssues()} />
    <Fieldset.Root>
        <Fieldset.Legend>Profile draft</Fieldset.Legend>
        <Fieldset.Description>
            This demo validates on the server. Nothing is stored. Try “admin” to see a server-side
            issue, or “system” for a form-level issue.
        </Fieldset.Description>
        <Field.Group>
            <Field.Root
                controlId={`${uid}-username`}
                required
                issues={draft.fields.username.issues()}
            >
                <Field.Label>Username</Field.Label>
                <Field.Control
                    describedBy={`${uid}-username-hint`}
                    errorId={`${uid}-username-error`}
                >
                    {#snippet children(control)}
                        <Input
                            {...control}
                            {...draft.fields.username.as('text')}
                            autocomplete="username"
                            placeholder="sam-rivera"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Description id={`${uid}-username-hint`}>
                    Between 3 and 40 characters.
                </Field.Description>
                <Field.Error aria-live="off" id={`${uid}-username-error`} />
            </Field.Root>
            <Field.Root controlId={`${uid}-email`} required issues={draft.fields.email.issues()}>
                <Field.Label>Email</Field.Label>
                <Field.Control errorId={`${uid}-email-error`}>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            {...draft.fields.email.as('email')}
                            autocomplete="email"
                            placeholder="sam@company.com"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Error aria-live="off" id={`${uid}-email-error`} />
            </Field.Root>
        </Field.Group>
    </Fieldset.Root>
    <Form.Actions>
        <Form.Submit
            {...draft.fields.intent.as('submit', 'validate')}
            labels={{ loading: 'Checking' }}
        >
            Validate draft
        </Form.Submit>
        <Form.Submit
            {...draft.fields.intent.as('submit', 'validate-reset')}
            variant="outline"
            labels={{ loading: 'Checking' }}
        >
            Validate and reset
        </Form.Submit>
        <Button type="reset" variant="ghost" disabled={draft.pending > 0}>Reset</Button>
    </Form.Actions>
</Form.Root>
~~~~

### Status Tone

~~~~svelte
<script lang="ts">
    import * as Form from '@mielui/svelte/components/form';
</script>

<div class="flex w-full max-w-sm flex-col gap-2">
    <Form.Status tone="neutral">Changes are saved when you press Save.</Form.Status>
    <Form.Status tone="success">Profile saved.</Form.Status>
    <Form.Status tone="error">We could not reach the server. Try again.</Form.Status>
</div>
~~~~

For the rendered reference, visit [/docs/components/form](/docs/components/form).
