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.
<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">
£
</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"andspellcheck="false"to stop browsers saving or spell-checking secrets - Use
pe-10on the input so the text never sits under the toggle; duplicate the base input classes since the input tag helper bails whenclassis 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
novalidateon every form — all validation is server-side via FluentValidationkoala-inline-validation-foron 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-forto get the red invalid border koala-loadingon submit buttons for spinner and click guard- Input model properties use
initaccessors, notset - 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-formust have anOnPostValidateFieldhandler - Secret inputs (API keys, tokens) use
type="password"with an Eye/EyeOff toggle — never render a saved secret back to the browser