Koala logo Design

Forms

Working validation demo showing every field type. In the Portal, forms use Alpine-AJAX with koala-inline-validation-for for per-field validation on blur.

£
Reset
<form method="post" x-target.push="main" novalidate>
    <!-- Text inputs (2-column) -->
    <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
        <div koala-inline-validation-for="Input.FirstName">
            <label asp-for="Input.FirstName"
                   class="block mb-2.5 font-medium text-gray-900 dark:text-white">First name</label>
            <input asp-for="Input.FirstName" placeholder=""/>
            <span asp-validation-for="Input.FirstName" class="mt-2 block"></span>
        </div>
        <div koala-inline-validation-for="Input.LastName">
            <label asp-for="Input.LastName"
                   class="block mb-2.5 font-medium text-gray-900 dark:text-white">Last name</label>
            <input asp-for="Input.LastName" placeholder=""/>
            <span asp-validation-for="Input.LastName" class="mt-2 block"></span>
        </div>
    </div>

    <!-- Email with icon prefix -->
    <div koala-inline-validation-for="Input.Email">
        <label asp-for="Input.Email"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Email</label>
        <input asp-for="Input.Email" koala-input-prefix="Email" placeholder=""/>
        <span asp-validation-for="Input.Email" class="mt-2 block"></span>
    </div>

    <!-- Currency input with £ prefix -->
    <div koala-inline-validation-for="Input.Amount">
        <label asp-for="Input.Amount"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Amount</label>
        <div class="relative">
            <div class="absolute inset-y-0 start-0 flex items-center ps-2.5
                        pointer-events-none dark:text-white">
                &pound;
            </div>
            <input asp-for="Input.Amount"
                   inputmode="numeric"
                   data-type="currency"
                   class="block w-full ps-7 pe-3 py-2.5 bg-white dark:bg-gray-700
                          border border-gray-200 dark:border-gray-600 text-gray-900
                          dark:text-white rounded-lg placeholder:gray-100"
                   placeholder=""/>
        </div>
        <span asp-validation-for="Input.Amount" class="mt-2 block"></span>
    </div>

    <!-- Secret input with show/hide toggle -->
    <div koala-inline-validation-for="Input.ApiKey">
        <label asp-for="Input.ApiKey"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">API key</label>
        <div class="relative" x-data="{ visible: false }">
            <input asp-for="Input.ApiKey"
                   type="password"
                   :type="visible ? 'text' : 'password'"
                   autocomplete="new-password"
                   spellcheck="false"
                   placeholder="Enter API key"
                   class="bg-white dark:bg-gray-700 border border-gray-200
                          dark:border-gray-600 text-gray-900 dark:text-white
                          rounded-lg block w-full py-2.5 ps-3 pe-10
                          placeholder:text-gray-400"/>
            <button type="button"
                    x-on:click="visible = !visible"
                    :aria-label="visible ? 'Hide API key' : 'Show API key'"
                    :title="visible ? 'Hide API key' : 'Show API key'"
                    tabindex="-1"
                    class="absolute inset-y-0 end-0 px-3 flex items-center
                           text-gray-500 hover:text-gray-700
                           dark:text-gray-400 dark:hover:text-gray-200">
                <koala-icon name="Eye" x-show="!visible" />
                <koala-icon name="EyeOff" x-show="visible" x-cloak />
            </button>
        </div>
        <span asp-validation-for="Input.ApiKey" class="mt-2 block"></span>
    </div>

    <!-- Alpine.js custom dropdown (no koala-inline-validation-for) -->
    <div>
        <label class="block mb-2.5 font-medium text-gray-900 dark:text-white">Category</label>
        <div x-data="{ open: false, selected: '' }" class="relative"
             x-on:click.outside="open = false">
            <button type="button" x-on:click="open = !open"
                    class="flex items-center justify-between w-full px-3 py-2.5
                           bg-white dark:bg-gray-700 border border-gray-200
                           dark:border-gray-600 text-gray-900 dark:text-white rounded-lg">
                <span x-text="selected || 'Select a category'"
                      :class="selected ? '' : 'text-gray-400'"></span>
                <koala-icon name="ChevronDown" size="Small" class="text-gray-400" />
            </button>
            <input type="hidden" name="Input.Category" :value="selected" />
            <div x-show="open" x-transition x-cloak
                 class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800
                        border border-gray-200 dark:border-gray-700 rounded-lg
                        shadow-lg py-1 overflow-hidden">
                <button type="button" x-on:click="selected = 'Sale'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Sale' ? 'bg-gray-50 dark:bg-gray-700' : ''">Sale</button>
                <button type="button" x-on:click="selected = 'Purchase'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Purchase' ? 'bg-gray-50 dark:bg-gray-700' : ''">Purchase</button>
                <button type="button" x-on:click="selected = 'Remortgage'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Remortgage' ? 'bg-gray-50 dark:bg-gray-700' : ''">Remortgage</button>
            </div>
        </div>
        <span asp-validation-for="Input.Category" class="mt-2 block"></span>
    </div>

    <!-- Textarea -->
    <div koala-inline-validation-for="Input.Notes">
        <label asp-for="Input.Notes"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Notes</label>
        <textarea asp-for="Input.Notes" rows="4" placeholder=""></textarea>
        <span asp-validation-for="Input.Notes" class="mt-2 block"></span>
    </div>

    <button type="submit" koala-loading koala-btn="Primary">Submit</button>
</form>

Invalid-state border on custom pickers

Native <input>/<select>/<textarea> elements with asp-for get the red input-validation-error class automatically when ModelState has an error. Custom Alpine dropdowns, radio groups, and any other non-native form control need the koala-invalid-for tag helper to match.

<button type="button"
        koala-invalid-for="Input.DefaultUserId"
        class="flex w-full items-center justify-between px-3 py-2.5 bg-white
               border border-gray-200 rounded-xl ...">
    ...
</button>

The tag helper adds input-validation-error to the element whenever ModelState["Input.DefaultUserId"] has errors, which triggers the same rose-400 border styling the inputs get. Works on any element — buttons, divs, whatever carries the picker's visible chrome.

Secret inputs

API keys, tokens, and other secrets use type="password" with an Eye/EyeOff toggle so users can verify what they're typing. Wrap the input in a relative div with x-data="{ visible: false }", bind :type="visible ? 'text' : 'password'" on the input, and place an absolutely-positioned toggle button at the right edge.

  • Keep a static type="password" so the value stays masked before Alpine hydrates
  • Add tabindex="-1" on the toggle so Tab moves to the next form field, not the eye
  • Set autocomplete="new-password" and spellcheck="false" to stop browsers saving or spell-checking secrets
  • Use pe-10 on the input so the text never sits under the toggle; duplicate the base input classes since the input tag helper bails when class is set
  • Never echo a saved secret back to the browser — render a masked placeholder (e.g. ••••••••1c2a) and require a fresh entry to change it

Key rules

  • novalidate on every form — all validation is server-side via FluentValidation
  • koala-inline-validation-for on the wrapping div of every field (requires Alpine-AJAX)
  • Custom dropdowns and radio buttons must not use koala-inline-validation-for
  • Custom pickers (Alpine dropdowns, radio groups, etc.) use koala-invalid-for to get the red invalid border
  • koala-loading on submit buttons for spinner and click guard
  • Input model properties use init accessors, not set
  • Use nullable types (string?) to avoid ASP.NET's implicit required validation
  • Never save data on blur — only the submit button triggers OnPost()
  • Every page model using koala-inline-validation-for must have an OnPostValidateField handler
  • Secret inputs (API keys, tokens) use type="password" with an Eye/EyeOff toggle — never render a saved secret back to the browser

Form actions

Bottom-of-form button row — flex-col on mobile, flex-row on desktop with gap-3. Replaces the boilerplate <div class="flex flex-col sm:flex-row gap-3"> wrapper around submit + cancel buttons.

Cancel
<koala-form-actions>
    <button type="submit" koala-loading koala-btn="Primary" class="w-full sm:w-auto">Save</button>
    <a href="#" koala-btn="Neutral" class="w-full sm:w-auto">Cancel</a>
</koala-form-actions>

Form field

Replaces the four-line <div koala-inline-validation-for> + <label asp-for> + <input asp-for> + <span asp-validation-for> quadruple. Saves ~5 lines per field across hundreds of form pages. For richer fields (autocomplete, dropdowns, custom layouts) keep using the explicit markup — this helper covers the simple text/email/tel/number case that dominates form pages.

<koala-field for="Input.FirstName" label="First name" />
<koala-field for="Input.EmailAddress" label="Email" type="email" input-prefix="Email" />
<koala-field for="Input.WebsiteUrl" label="Website" type="url" optional="true" />
<koala-field for="Input.Phone" label="Phone" inputmode="numeric" class="mb-5" />

Attributes: for (required), label, type, placeholder, autocomplete, required, optional, input-prefix (Email/Phone), inputmode, class.

Auto-required labels

<label asp-for> automatically appends a red asterisk if the bound property has a FluentValidation NotEmpty() or NotNull() rule — so authors don't have to mark required state manually. For custom dropdowns and radio groups (which don't bind via asp-for), use koala-required="true" explicitly.

<!-- Validator has NotEmpty() — asterisk auto-rendered -->
<label asp-for="Input.FirstName">First name</label>

<!-- Custom dropdown with no asp-for binding — explicit -->
<label koala-required="true">Role</label>

Form-level errors

Renders the form-level model-state errors (the string.Empty ModelState key) in a red alert box at the top of the form. Used for cross-field validation errors and other errors that aren't bound to a specific field.

<form method="post" novalidate>
    <koala-form-errors />
    <koala-field for="Input.Name" label="Name" />
    ...
</form>

Yes/No radio pills

Pill-style Yes/No radio group. Replaces ~25 lines of repeated peer-checked Tailwind markup per question on the quote-create flow.

<koala-radio-yes-no for="IsNewBuild" label="Is the property a new build?" />
<koala-radio-yes-no for="IsUsingMortgage" label="Mortgage?" class="mt-8" />

Radio groups must not use koala-inline-validation-for — the change-fired AJAX re-render resets scroll position. This helper validates on submit only.

Preserve input across multi-step flows

Walks an input model and renders <input type="hidden"> tags for every scalar property. Used in multi-step flows (quote-create, partner-create) where the next-step form needs to round-trip the previous-step data without losing it.

<form method="post" asp-page-handler="Step2">
    <koala-preserve-input prefix="Input" model="@Model.Input" />
    <!-- step 2 fields -->
</form>

CRM picker

<koala-crm-select for="..."/> renders the canonical CRM dropdown used on every branch create/edit form (Reapit / MRI Software / Jupix / Alto / Expert Agent / Rex Software / Dezrez / Street.co.uk / Loop / AgentOS, plus a "Select CRM" placeholder). Centralising the option list means adding a new CRM is a one-file change. Portal-only.

<koala-crm-select for="Input.Crm" />

<!-- with a custom wrapper class (default is mb-5) -->
<koala-crm-select for="Input.Crm" class="mb-8" />

Yes / No radio group

<koala-radio-yes-no for="..." label="..."/> renders a two-pill radio group for boolean questions. Replaces ~25 lines of peer-checked Tailwind markup per question (used for every "is this a new build / is there a mortgage / is this shared ownership" prompt in the quote-create flow). The helper reads the parent partial's HtmlFieldPrefix automatically so name/id match what asp-for would generate.

Note: per CLAUDE.md, radio groups must NOT use koala-inline-validation-for — change-fired AJAX would reset Alpine state and tray scroll. This helper validates on submit only.

<koala-radio-yes-no for="IsNewBuild" label="Is the property a new build?" />
<koala-radio-yes-no for="IsUsingMortgage"
                    label="Is a mortgage being used to buy the property?"
                    class="mt-8" />