← Recipe: validate a form and confirm in a modal

3. Replace src/main.luau

luastra/ui · luastra/data · controlled input · about 20 minutes
controlled fields → validation → modal → confirmation

Every visible value and error comes from Luau state; the modal opens only after normalized values pass both validation layers.

When to use it

Replace the complete entry module. Keep modal visibility in state and use the same dismissal action for Escape, backdrop dismissal, and the visible cancel button.

Focused example

--!strict

local Data = require("luastra/data")
local UI = require("luastra/ui")

local nameSchema = Data.string { minBytes = 2, maxBytes = 60, trim = true }
local emailSchema = Data.string { minBytes = 3, maxBytes = 160, trim = true }

local App = require("luastra/app")
local Root = {}
local name = ""
local email = ""
local nameError: string? = nil
local emailError: string? = nil
local modalOpen = false
local status = "Complete both fields"
local submittedName: string? = nil

local function validEmail(value: string): boolean
    return string.match(value, "^[^%s@]+@[^%s@]+%.[^%s@]+$") ~= nil
end

local function validate(): boolean
    local checkedName = Data.decode(nameSchema, name)
    local checkedEmail = Data.decode(emailSchema, email)
    if checkedName.success then nameError = nil
    else nameError = "Enter 2 to 60 bytes." end
    if checkedEmail.success then emailError = nil
    else emailError = "Enter an email address." end
    if checkedEmail.success and not validEmail(checkedEmail.value) then
        emailError = "Use a format such as name@example.com."
    end
    if not checkedName.success or not checkedEmail.success or emailError ~= nil then
        status = "Fix the highlighted fields"
        return false
    end
    name = checkedName.value
    email = checkedEmail.value
    modalOpen = true
    status = "Review the normalized values"
    return true
end

function Root.render(): UI.Node
    return UI.Screen {
        id = "profile",
        width = "content",
        padding = "responsive",
        UI.Card {
            id = "profile/form",
            gap = "md",
            padding = "lg",
            surface = "elevated",
            UI.Text { id = "profile/title", text = "Create profile", variant = "title" },
            UI.Field {
                id = "profile/name-field",
                gap = "xs",
                role = "group",
                label = "Name field",
                UI.Text { id = "profile/name-label", text = "Name" },
                UI.TextInput {
                    id = "profile/name",
                    label = "Name",
                    value = name,
                    onInput = "form.name",
                    autoComplete = "name",
                    enterKeyHint = "next",
                    required = true,
                    errorId = nameError ~= nil and "profile/name-error" or nil,
                },
                UI.Text {
                    id = "profile/name-error",
                    text = nameError or "",
                    role = "alert",
                    tone = "error",
                    hidden = nameError == nil,
                },
            },
            UI.Field {
                id = "profile/email-field",
                gap = "xs",
                role = "group",
                label = "Email field",
                UI.Text { id = "profile/email-label", text = "Email" },
                UI.TextInput {
                    id = "profile/email",
                    label = "Email",
                    value = email,
                    onInput = "form.email",
                    inputType = "email",
                    inputMode = "email",
                    autoComplete = "email",
                    enterKeyHint = "done",
                    required = true,
                    errorId = emailError ~= nil and "profile/email-error" or nil,
                },
                UI.Text {
                    id = "profile/email-error",
                    text = emailError or "",
                    role = "alert",
                    tone = "error",
                    hidden = emailError == nil,
                },
            },
            UI.Text { id = "profile/status", text = status, role = "status" },
            UI.Actions {
                id = "profile/actions",
                responsive = true,
                UI.Button {
                    id = "profile/review",
                    text = "Review",
                    onTap = "form.review",
                    appearance = "primary",
                },
            },
        },
        UI.Modal {
            id = "profile/confirm-modal",
            label = "Confirm profile",
            open = modalOpen,
            onDismiss = "form.cancel-review",
            UI.Stack {
                id = "profile/confirm-content",
                gap = "md",
                padding = "lg",
                UI.Text { id = "profile/confirm-title", text = "Confirm profile", variant = "heading" },
                UI.Text { id = "profile/confirm-name", text = "Name: " .. name },
                UI.Text { id = "profile/confirm-email", text = "Email: " .. email },
                UI.Actions {
                    id = "profile/confirm-actions",
                    responsive = true,
                    UI.Button {
                        id = "profile/confirm",
                        text = "Confirm",
                        onTap = "form.confirm",
                        appearance = "primary",
                    },
                    UI.Button {
                        id = "profile/cancel",
                        text = "Keep editing",
                        onTap = "form.cancel-review",
                        appearance = "secondary",
                    },
                },
            },
        },
    }
end

function Root.handle(action: string, target: string, value: string)
    if action == "form.name" and target == "profile/name" then
        name = value
        nameError = nil
    elseif action == "form.email" and target == "profile/email" then
        email = value
        emailError = nil
    elseif action == "form.review" and target == "profile/review" then
        validate()
    elseif action == "form.cancel-review"
        and (target == "profile/cancel" or target == "profile/confirm-modal") then
        modalOpen = false
        status = "Continue editing"
    elseif action == "form.confirm" and target == "profile/confirm" and modalOpen then
        modalOpen = false
        submittedName = name
        status = "Profile confirmed for " .. name
    end
end

function Root.snapshot()
    return {
        name = name,
        email = email,
        nameError = nameError,
        emailError = emailError,
        modalOpen = modalOpen,
        status = status,
        submittedName = submittedName,
    }
end

return App.compose {
    render = function(_outputs) return Root.render() end,
    handle = Root.handle,
    snapshot = Root.snapshot,
}

Complete inventory

View all pages in Recipe: validate a form and confirm in a modal
← Previous: 2. Replace luastra.jsonNext: 4. Replace tests/smoke.luau →