Lumeo

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 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 + a section heading, or with an 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 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 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