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-surface
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-surface 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-surface 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-surface
border border-border-default 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
Inline field validation
Every form field that needs per-field validation on blur must be wrapped in a div with
koala-inline-validation-for.
<div koala-inline-validation-for="Input.FieldName">
<label asp-for="Input.FieldName" class="block mb-2.5 font-medium text-gray-900 dark:text-white">Label</label>
<input asp-for="Input.FieldName" placeholder=""/>
<span asp-validation-for="Input.FieldName" class="mt-2 block"></span>
</div>
Requires a matching OnPostValidateField
handler in the page model. Custom dropdowns and radio buttons must NOT use this attribute.
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
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.
<koala-form-actions>
<button type="submit" koala-loading koala-btn="Primary" class="w-full sm:w-auto">Save</button>
<a href="#" koala-btn="Outline" 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 muted (required) suffix
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. (required) reads more clearly than an asterisk for non-form-savvy
users — the word is unambiguous; the muted gray keeps it secondary to the label itself.
<!-- Validator has NotEmpty() — (required) suffix 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.
Step heading (_StepHeading)
Heading partial for each step in a multi-step form (create-quote, onboarding, settings sub-flows).
Renders an <h2> with a thin edge-to-edge divider underneath, so the section the
user is on reads as its own bordered block inside the surrounding card. Pair with the footer
action row (_FormButtons / _ClientFormButtons) which carries the same
edge-to-edge divider above its buttons — heading + footer frame the step content top and bottom.
Tell us about the property you're buying
Step content goes here — questions, inputs, etc.
<partial name="_StepHeading" model="Tell us about the property you're buying" />
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" />
"I don't know yet" toggle
Used on purchase quote creation to let buyers proceed without an address — they tick the box, the
address fields hide, validation skips the address sub-rules, and the quote saves with a null
Address. The address is filled in later (in Hoowla, post-instruction) and syncs back
via HoowlaTransactionSyncService.SyncAddressAsync.
Pattern: a nullable bool? on the input model with an
x-data-scoped Alpine state, a paired hidden input so the unchecked value round-trips
correctly, and an x-show wrapping the optional fields. The validator branches on the
flag and skips its address sub-validator when set.
Read-side pair: views that render the optional value use
_AddressTbcCallout.cshtml (an inline amber callout matching the design system warning
style) when the field is null, plus _QuotePurchaseAddressLine.cshtml /
_TransactionAddressLine.cshtml for compact list-card / table-row rendering — same
muted-italic "Still house hunting" fallback, one partial per aggregate.
Property attribute defaults: when the address is unknown,
QuotePurchase persists assumed defaults (House / Freehold / not new build / not shared
ownership — see QuotePurchase.Default* constants). The create-form hint and the
read-side callout both spell out the assumption so the customer can see what the price is based on,
and Hoowla receives real values rather than nulls.
Tick this if the buyer is still searching. We'll fill the address in once it's confirmed.
👌 No rush — we'll add the address once you've found the one!
In the meantime we'll calculate your quote based on:
- Freehold
- House
- Not a new build
- Not part of a shared ownership scheme
<div x-data="{ addressUnknown: @(Model.AddressUnknown == true ? "true" : "false") }">
<div class="mb-4">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="hidden" name="@Html.NameFor(model => model.AddressUnknown)" value="false" />
<input asp-for="AddressUnknown" type="checkbox" value="true"
x-model="addressUnknown"
class="h-5 w-5 rounded ..." />
<span>I don't know the address yet</span>
</label>
</div>
<div x-show="!addressUnknown">
<partial name="_Address" for="PropertyDetails.Address" />
</div>
</div>
Switch (<koala-switch>)
iOS-style track + sliding thumb backed by a real checkbox. Use for any binary on/off form input — settings toggles, "I don't know yet"-style escape hatches, multi-select rows where each row contributes a value to a posted list. Never roll the inline 14-line Tailwind salad; that's exactly what this tag helper exists to replace.
Reject new build purchases
Off in this demo. Click the switch to flip.
Notifications enabled
On in this demo.
Inline-label variant — the text and the track are inside one <label> so the text is also a click target.
Pill variant (variant="Pill")
Two-button segmented pill — same shape as the £/% discount amount-type toggle. Use when the binary needs explicit labels for both states ("Yes / No", "On / Off") rather than a single on/off track. Compact: text-sm buttons, gray-500 supporting label that's also clickable to flip the pill. Active text defaults to full gray-900 / white; pass subtle="true" when the pill is a quiet secondary toggle next to a primary input (e.g. the address widget's "Still house hunting?") and the active text should match the muted label colour.
@* Property-bound (most common) *@
<koala-switch for="OrganisationInput.RejectNewBuildPurchases" />
@* Inline label *@
<koala-switch for="AddressUnknown" label="Still house hunting? 🏠" />
@* Pill variant (Yes/No segmented) *@
<koala-switch for="AddressUnknown" label="Still house hunting?" variant="Pill" />
@* Subtle pill — active text matches the muted label colour *@
<koala-switch for="AddressUnknown" label="Still house hunting?" variant="Pill" subtle="true" />
@* Pill with custom labels *@
<koala-switch for="IsPercentage" variant="Pill" on-label="%" off-label="£" />
@* Dynamic name (loops, multi-select-as-switch) *@
@foreach (var (status, fieldName, isChecked) in toggles)
{
<koala-switch name="@fieldName" checked="@isChecked" />
}
@* Auto-submit on change *@
<koala-switch name="IsHoowlaEnabled" checked="@Model.IsHoowlaEnabled"
x-on:change="$el.form.requestSubmit()" />
@* Alpine state binding (parent x-data scope provides the variable) *@
<koala-switch for="AddressUnknown" x-model="addressUnknown" />