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
- Sheet — the underlying component
- Service vs markup overlays — when to await an OverlayService call instead
- Mobile sheet patterns — bottom sheets, snap points, swipe-to-close
- EditForm + Lumeo Validation — validation wiring