Koala logo Design

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)">&times;</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.

Specific branches

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)">
                        &times;
                    </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 _LayoutAjax re-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 with AbortController for 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-result attributes on each item and data-kb-active to 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.outside closes the dropdown when clicking anywhere else on the page
  • Exclude IDs: person autocomplete passes already-selected IDs to the server to avoid showing duplicates