Autocomplete
Autocomplete patterns used across the Portal. The mega search and address lookup demos use real
server endpoints returning HTML partials — matching the Portal’s exact patterns.
Person and branch autocomplete use fetch() to JSON endpoints with client-side rendering.
Mega search
The global search in the navbar. Uses fetch() to call a server endpoint that returns
an HTML partial, with keyboard navigation via data-result and data-kb-active
attributes. Reference: Pages/Shared/_Navbar.cshtml and Pages/Search.cshtml.cs.
Try typing "elm", "green", "Q-100", or "T-200". Results are fetched from a server endpoint. Use arrow keys to navigate, Enter to select, Escape to close.
<div x-data="{
query: '',
loading: false,
showResults: false,
activeIndex: -1,
getResultItems() {
var container = document.getElementById('search-results-dropdown');
return container ? Array.from(container.querySelectorAll('[data-result]')) : [];
},
highlight() {
var self = this;
var items = self.getResultItems();
items.forEach(function(el, i) {
if (i === self.activeIndex) {
el.setAttribute('data-kb-active', '');
el.scrollIntoView({ block: 'nearest' });
} else {
el.removeAttribute('data-kb-active');
}
});
},
navigate(direction) {
var items = this.getResultItems();
if (items.length === 0) return;
if (direction === 'down') {
this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1);
} else {
this.activeIndex = Math.max(this.activeIndex - 1, 0);
}
this.highlight();
},
selectActive() {
if (this.activeIndex < 0) return false;
var items = this.getResultItems();
if (items[this.activeIndex]) {
items[this.activeIndex].click();
return true;
}
return false;
},
doSearch() {
var self = this;
self.activeIndex = -1;
if (!self.query.trim()) {
self.showResults = false;
return;
}
self.loading = true;
fetch('/search?handler=Results&query=' + encodeURIComponent(self.query))
.then(function(r) { return r.text(); })
.then(function(html) {
document.getElementById('search-results-dropdown').innerHTML = html;
self.showResults = true;
self.loading = false;
});
},
close() {
this.showResults = false;
this.activeIndex = -1;
}
}">
<input type="search"
x-model="query"
x-on:input.debounce.300ms="doSearch()"
x-on:focus="if (query.trim()) { showResults = true; }"
x-on:keydown.down.prevent="navigate('down')"
x-on:keydown.up.prevent="navigate('up')"
x-on:keydown.enter="if (selectActive()) { $event.preventDefault(); }"
x-on:keydown.escape="close()"
autocomplete="off"
placeholder="Search..." />
<!-- Loading spinner -->
<div x-show="loading">
<svg class="h-5 w-5 animate-spin" ...></svg>
</div>
<!-- Results dropdown (HTML injected from server) -->
<div x-show="showResults"
x-on:click.outside="close()">
<div id="search-results-dropdown"></div>
</div>
</div>
Address lookup
Three-state pattern: search (type-ahead lookup), select (address list), and summary
(read-only display). Uses fetch() with anti-forgery token to POST to page handlers
that return HTML partials. Reference: Pages/Shared/_Address.cshtml.
Try typing "elm", "flat", or "salford". Results are fetched from a server endpoint via fetch(). Click a result to select, then click Edit to go back.
<!-- Hidden form for anti-forgery token -->
<form method="post" id="address-form" style="display: none;">
@Html.AntiForgeryToken()
</form>
<!-- State container swapped via fetch() -->
<div id="address-container">
<!-- Search state -->
<div class="relative">
<input type="search"
x-model="query"
x-on:input.debounce.300ms="searchAddress()" />
<div id="address-results" x-show="showResults">
<!-- Server returns address list -->
</div>
</div>
</div>
<!-- Methods use fetch() with anti-forgery token -->
searchAddress() {
var formData = new FormData();
formData.append('__RequestVerificationToken', getToken());
fetch('/page?handler=SearchAddress&query=...', {
method: 'POST',
body: formData
})
.then(r => r.text())
.then(html => {
document.getElementById('address-results').innerHTML = html;
});
}
selectAddress(id) {
// Same pattern — POST with token, swap address-container
}
unselectAddress() {
// Same pattern — POST with token, swap address-container
}
Person autocomplete
Fetch-based pattern with AbortController for cancellation, multi-select support,
and exclude IDs to filter already-selected people. Chips live inside the input container matching
Portal’s _PersonAutocomplete.cshtml pattern. Reference:
Pages/Shared/_PersonAutocomplete.cshtml.
No people found
Try typing "sa", "james", or "brown". Click a result to add, click the X to remove. Uses fetch() with AbortController to a JSON endpoint. Chips live inside the input container.
<div x-data="{
query: '',
results: [],
selected: [],
showDropdown: false,
activeIndex: -1,
loading: false,
abortController: null,
search() {
if (this.query.length < 2) {
this.results = [];
this.showDropdown = false;
return;
}
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
var signal = this.abortController.signal;
var excludeIds = this.selected.map(p => p.id).join(',');
var url = '/api/people?query=' + encodeURIComponent(this.query)
+ '&excludeIds=' + excludeIds;
var self = this;
fetch(url, { signal: signal })
.then(r => r.json())
.then(data => {
if (!signal.aborted) {
self.results = data;
self.loading = false;
}
})
.catch(e => { if (e.name !== 'AbortError') throw e; });
},
add(person) {
this.selected.push(person);
this.query = '';
this.results = [];
this.showDropdown = false;
},
remove(id) {
this.selected = this.selected.filter(p => p.id !== id);
}
}">
<!-- Chips + input in shared border container -->
<div class="flex flex-wrap items-center gap-1.5 min-h-[42px] px-3 py-1.5 bg-white border rounded-lg">
<template x-for="person in selected" :key="person.id">
<span class="inline-flex items-center gap-1.5 bg-gray-100 rounded-full pl-1 pr-2.5 py-1 text-sm">
<span class="w-6 h-6 rounded-full bg-secondary-100 flex items-center justify-center text-xs"
x-text="person.initials"></span>
<span x-text="person.name"></span>
<button type="button" x-on:click.stop="remove(person.id)">×</button>
</span>
</template>
<input type="search" x-ref="searchInput" x-model="query"
x-on:input.debounce.300ms="search()"
style="border: none !important; outline: none !important; box-shadow: none !important;"
class="min-w-[140px] flex-1 bg-transparent p-0"
placeholder="Search people..." />
</div>
<!-- Hidden inputs for form binding -->
<template x-for="person in selected" :key="person.id">
<input type="hidden" name="Input.PersonIds" :value="person.id" />
</template>
<!-- Dropdown with data-result and data-kb-active -->
<div x-show="showDropdown">
<template x-for="(person, index) in results" :key="person.id">
<button data-result :data-kb-active="index === activeIndex || undefined"
x-on:click="add(person)">
<span x-text="person.initials"></span>
<span x-text="person.name"></span>
</button>
</template>
</div>
</div>
Branch autocomplete
Client-side filtering pattern. All branches are loaded upfront as JSON, then filtered
with Alpine.js as the user types. Supports toggling between “all” and “specific” mode.
Selected items shown as pills inside the input container. Reference:
Pages/Shared/_BranchAutocomplete.cshtml.
User will have access to all branches.
No branches found
Toggle “Specific branches” on, then type "man", "leeds", or "bridge". Click a result to add, click the X to remove. All data loaded upfront — no server calls.
<!-- Branches serialized as JSON in the page -->
<script id="branch-data" type="application/json">
@Html.Raw(Json.Serialize(branches))
</script>
<div x-data="{
query: '',
accessMode: 'partner',
branches: [],
selectedIds: [],
showDropdown: false,
activeIndex: -1,
get filtered() {
var self = this;
return self.branches
.filter(b => !self.selectedIds.includes(b.id))
.filter(b => !self.query
|| b.name.toLowerCase().includes(self.query.toLowerCase()));
},
getBranch(id) {
return this.branches.find(b => b.id === id);
}
}"
x-init="branches = JSON.parse(document.getElementById('branch-data').textContent)">
<!-- Toggle: all vs specific -->
<button x-on:click="accessMode === 'partner' ? switchToBranch() : switchToPartner()">
Specific branches
</button>
<!-- Partner mode: help text -->
<p x-show="accessMode === 'partner'">User will have access to all branches.</p>
<!-- Branch mode: chips + input in shared container -->
<div x-show="accessMode === 'branch'" class="relative">
<div class="flex flex-wrap items-center gap-2 border rounded-lg px-3 py-2">
<template x-for="id in selectedIds" :key="id">
<span class="inline-flex items-center gap-1.5 bg-gray-200 rounded-full px-2.5 py-1">
<span x-text="getBranch(id)?.name"></span>
<button x-on:click.stop="selectedIds = selectedIds.filter(i => i !== id)">
×
</button>
</span>
</template>
<input type="search" x-ref="branchSearchInput" x-model="query"
style="border: none !important; outline: none !important; box-shadow: none !important;"
class="min-w-[140px] flex-1 bg-transparent p-0"
placeholder="Search branches..." />
</div>
<!-- Hidden inputs -->
<template x-for="id in selectedIds" :key="id">
<input type="hidden" name="Input.BranchIds" :value="id" />
</template>
<!-- Dropdown -->
<div x-show="showDropdown">
<template x-for="(branch, index) in filtered" :key="branch.id">
<button data-result :data-kb-active="index === activeIndex || undefined"
x-on:click="selectedIds.push(branch.id)">
<span x-text="branch.name"></span>
</button>
</template>
</div>
</div>
</div>
Implementation notes
- 300ms debounce is the standard delay for all server-side autocompletes
- Mega search uses
fetch()because the target container lives in the navbar layout partial — Alpine-AJAX can’t work there due to_LayoutAjaxre-renders creating duplicate IDs - Address lookup uses
fetch()with an anti-forgery token from a hidden form for POST requests that return HTML partials - Person autocomplete uses
fetch()to a JSON endpoint withAbortControllerfor request cancellation. Chips live inside the input container, matching Portal’s_PersonAutocomplete.cshtml - Branch autocomplete loads all data upfront as JSON and filters client-side. Supports “all branches” vs “specific branches” toggle. Pills inside the input container, matching Portal’s
_BranchAutocomplete.cshtml - Keyboard navigation uses
data-resultattributes on each item anddata-kb-activeto track the focused result - Drop-up detection: when viewport space below the input is less than 280px, the dropdown opens upward instead
x-on:click.outsidecloses the dropdown when clicking anywhere else on the page- Exclude IDs: person autocomplete passes already-selected IDs to the server to avoid showing duplicates