# Long Forms in Sheets

Source: https://lumeo.nativ.sh/docs/long-forms-in-sheets

# Long Forms in Sheets

Sheets are the canonical place to host non-trivial forms: edit a record, configure a resource, or run a multi-step wizard without leaving the current page. The pattern works well up to several hundred fields, but only if you handle scroll, header/footer stickiness, validation surfacing, unsaved-change protection, and submission state correctly. This guide walks through the layout primitives Lumeo provides and the wiring that keeps long forms usable.

## Why Sheets, not Dialogs

[Dialog](components/dialog) caps at the viewport and centers its content — it cannot scroll a 40-field form gracefully. Sheets slide in from an edge, occupy a fixed width (typically `md` or `lg`) and own the full viewport height. That gives you a dedicated scroll container, a sticky header for the title / close button, and a sticky footer for the primary action — exactly the chrome a form needs.

-   Forms under ~10 fields with no grouping — use Dialog.
-   Forms with sections, repeating rows, or steps — use Sheet.
-   Forms that need full-page real estate (DataGrid editor, rich-text body) — use a dedicated route.

## Canonical layout

Wrap the form body in a scroll region and pin the action row to the bottom. The header stays at the top via `SheetHeader`; the footer pins via `SheetFooter`.

<Sheet @bind-Open="\_open">
    <SheetContent Side="Lumeo.Side.Right" Size="SheetContent.SheetSize.Lg">
        <SheetHeader>
            <SheetTitle>Edit project</SheetTitle>
            <SheetDescription>Update the project metadata and visibility.</SheetDescription>
        </SheetHeader>

        <EditForm Model="@\_form" OnValidSubmit="SaveAsync" class="flex flex-col flex-1 min-h-0">
            <DataAnnotationsValidator />

            <div class="flex-1 overflow-y-auto px-6 py-4">
                <Stack Gap="5">
                    <FormField Label="Name"><Input @bind-Value="\_form.Name" /></FormField>
                    <FormField Label="Slug"><Input @bind-Value="\_form.Slug" /></FormField>
                    <FormField Label="Description"><Textarea @bind-Value="\_form.Description" Rows="6" /></FormField>
                    @\* ... more fields ... \*@
                </Stack>
            </div>

            <SheetFooter>
                <Button Variant="Button.ButtonVariant.Outline" OnClick="() => \_open = false">Cancel</Button>
                <Button type="submit" IsLoading="@\_saving">Save</Button>
            </SheetFooter>
        </EditForm>
    </SheetContent>
</Sheet>

The `flex-1 overflow-y-auto` wrapper is the scroll viewport. Without it, long content pushes the footer off-screen and the sheet itself starts scrolling, which loses the sticky-action affordance.

## Grouping with sections

Past about a dozen fields, flat lists become hard to scan. Group related fields with [Separator](components/separator) + a section heading, or with an [Accordion](components/accordion) for optional / advanced sections that should collapse by default.

<Stack Gap="8">
    <Stack Gap="4">
        <Heading Level="3" Size="sm">Identity</Heading>
        <FormField Label="Name"><Input @bind-Value="\_form.Name" /></FormField>
        <FormField Label="Slug"><Input @bind-Value="\_form.Slug" /></FormField>
    </Stack>

    <Separator />

    <Stack Gap="4">
        <Heading Level="3" Size="sm">Visibility</Heading>
        <FormField Label="Public"><Switch @bind-Value="\_form.IsPublic" /></FormField>
        <FormField Label="Audience"><Select @bind-Value="\_form.Audience">...</Select></FormField>
    </Stack>

    <Accordion>
        <AccordionItem Value="advanced" Title="Advanced">
            <Stack Gap="4">
                <FormField Label="Webhook URL"><Input @bind-Value="\_form.WebhookUrl" /></FormField>
                <FormField Label="Custom headers"><Textarea @bind-Value="\_form.Headers" /></FormField>
            </Stack>
        </AccordionItem>
    </Accordion>
</Stack>

## Validation that respects scroll

When `OnInvalidSubmit` fires with a field below the fold, the user sees no change and assumes the button is broken. Scroll the first invalid field into view and focus it. Lumeo's [EditForm wiring](docs/edit-form) exposes the failing fields through `EditContext.GetValidationMessages()`.

private async Task OnInvalidSubmit(EditContext ctx)
{
    var firstBadField = ctx.GetValidationMessages()
        .Select(m => m)
        .FirstOrDefault();
    if (firstBadField is null) return;

    // Inputs include data-field-name; query and scroll into view.
    await Interop.InvokeVoidAsync("lumeoScrollFieldIntoView", \_formRef);
}

## Dirty-state confirmation on close

Users hit Escape or click the backdrop reflexively. If the form has unsaved edits, pop a confirmation. Track dirty state with `EditContext.OnFieldChanged` and intercept `OpenChanged` on the Sheet.

\[CascadingParameter\] private EditContext? \_editContext { get; set; }
private bool \_dirty;
private bool \_open;

protected override void OnAfterRender(bool firstRender)
{
    if (firstRender && \_editContext is not null)
        \_editContext.OnFieldChanged += (\_, \_) => { \_dirty = true; StateHasChanged(); };
}

private async Task OnOpenChanged(bool next)
{
    if (next || !\_dirty) { \_open = next; return; }

    var result = await Overlays.ShowAlertDialogAsync(new()
    {
        Title = "Discard changes?",
        Description = "You have unsaved edits. Closing will lose them.",
        ConfirmText = "Discard",
        IsDestructive = true,
    });
    if (!result.Cancelled) { \_open = false; \_dirty = false; }
}

<Sheet Open="@\_open" OpenChanged="OnOpenChanged" ...>

## Submission state

Block double-submits with a `bool _saving` flag bound to the submit button's `Loading` parameter. Also disable the close button while saving, and surface API errors via [Alert](components/alert) inside the sheet instead of toasts — toasts disappear; the sheet is still open and the user expects feedback in context.

private bool \_saving;
private string? \_serverError;

private async Task SaveAsync()
{
    \_saving = true;
    \_serverError = null;
    try
    {
        await Api.SaveAsync(\_form);
        \_open = false;
    }
    catch (ApiException ex)
    {
        \_serverError = ex.Message;
    }
    finally { \_saving = false; }
}

<!-- inside the scroll viewport, above the form body: -->
@if (\_serverError is not null)
{
    <Alert Variant="Alert.AlertVariant.Destructive" Title="Save failed">@\_serverError</Alert>
}

## See also

-   [Sheet](components/sheet) — the underlying component
-   [Service vs markup overlays](docs/service-vs-markup-overlays) — when to await an OverlayService call instead
-   [Mobile sheet patterns](docs/mobile-sheet-patterns) — bottom sheets, snap points, swipe-to-close
-   [EditForm + Lumeo Validation](docs/edit-form) — validation wiring
