Koala logo Design

Autocomplete

Autocomplete patterns used across the Portal. All demos use Alpine-AJAX with server endpoints returning HTML partials — matching the Portal’s exact patterns. Branch autocomplete uses client-side filtering with data loaded upfront.

Mega search

The global search in the navbar. Uses Alpine-AJAX 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() {
        this.activeIndex = -1;
        if (!this.query.trim()) {
            this.showResults = false;
            return;
        }
        this.$refs.searchLink.href = '/search?handler=Results&query='
            + encodeURIComponent(this.query);
        this.$refs.searchLink.click();
    },
    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 Alpine-AJAX with hidden forms 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 Alpine-AJAX. Click a result to select, then click Edit to go back.

<!-- Hidden forms for Alpine-AJAX POST requests -->
<form x-ref="searchAddressForm" method="post" asp-page-handler="SearchAddress"
      x-target="address-results" class="hidden">
    <input x-ref="searchAddressQuery" type="hidden" name="query" />
</form>
<form x-ref="selectAddressForm" method="post" asp-page-handler="SelectAddress"
      x-target="address-container" class="hidden">
    <input x-ref="selectAddressId" type="hidden" name="addressId" />
</form>

<!-- State container swapped via Alpine-AJAX -->
<div id="address-container">
    <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 submit hidden forms -->
searchAddress() {
    this.$refs.searchAddressQuery.value = this.query;
    this.$refs.searchAddressForm.requestSubmit();
}

selectAddress(id) {
    this.$refs.selectAddressId.value = id;
    this.$refs.selectAddressForm.requestSubmit();
}

Person autocomplete

Alpine-AJAX pattern with server-rendered HTML results, 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 Alpine-AJAX to a server endpoint returning HTML. Chips live inside the input container.

<div x-data="{
    query: '',
    selected: [],
    showDropdown: false,
    activeIndex: -1,
    loading: false,

    search() {
        if (this.query.length < 2) {
            this.showDropdown = false;
            return;
        }
        this.showDropdown = true;
        this.activeIndex = -1;
        var excludeIds = this.selected.map(p => p.id).join(',');
        this.$refs.searchLink.href = '/page?handler=SearchUsers&query='
            + encodeURIComponent(this.query)
            + '&excludeIds=' + excludeIds;
        this.$refs.searchLink.click();
    },

    addFromButton(event) {
        var btn = event.currentTarget;
        this.selected.push({
            id: btn.dataset.userId,
            name: btn.dataset.userName,
            initials: btn.dataset.userInitials
        });
        this.query = '';
        this.showDropdown = false;
    },

    remove(id) {
        this.selected = this.selected.filter(p => p.id !== id);
    }
}">
    <!-- Hidden link for Alpine-AJAX -->
    <a x-ref="searchLink" href="" x-target="person-results"
       x-on:ajax:before="loading = true"
       x-on:ajax:after="loading = false"
       class="hidden"></a>

    <!-- Chips + input -->
    <div class="flex flex-wrap items-center gap-1.5 ...">
        <template x-for="person in selected" :key="person.id">
            <span>...chip...</span>
        </template>
        <input type="search" x-model="query"
               x-on:input.debounce.300ms="search()" />
    </div>

    <!-- Dropdown with server-rendered results -->
    <div x-show="showDropdown"
         x-on:click="if ($event.target.closest('[data-result]')) addFromButton($event)">
        <div id="person-results">
            <!-- Server returns HTML with data-result buttons -->
        </div>
    </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 Alpine-AJAX with a hidden <a x-target> link. Desktop and mobile dropdowns are synced via an ajax:merged event listener
  • Address lookup uses Alpine-AJAX with hidden forms for POST requests. Three forms target different containers: search results, selected address, and unselect (back to search)
  • Person autocomplete uses Alpine-AJAX with a hidden <a x-target> link to a server endpoint returning HTML. 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