bassjobsen/bootstrap-3-typeahead
A Bootstrap 3 autocomplete plugin that adds typeahead suggestions to text inputs. Forked from Twitter's typeahead, it offers flexible data sources, custom display templates, and easy integration with Bootstrap forms.
bassjobsen/bootstrap-3-typeahead is not a Laravel package — it's a jQuery plugin that restores Bootstrap 2's $.fn.typeahead after Bootstrap 3 removed it. There is no service provider, facade, or config file. Your integration surface is: one JS asset, one Blade input, one JSON route.
Install
npm install bootstrap-3-typeahead # v4.0.2, published 2016-09-26
or via CDN (drop the defer if your init script runs inline):
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/bootstrap3-typeahead.min.js"></script>
Wire it into Vite. The plugin's IIFE reads window.jQuery at evaluation time, so order matters more than imports look like they should:
// resources/js/app.js
import $ from 'jquery'
window.$ = window.jQuery = $ // must exist BEFORE the next import
import 'bootstrap-3-typeahead' // attaches $.fn.typeahead
If the optimizer hoists it, add optimizeDeps: { include: ['bootstrap-3-typeahead'] } in vite.config.js, and import the file by its full name (bootstrap-3-typeahead/bootstrap3-typeahead.min.js) if the package main field misbehaves in your bundler.
Where to look first: the package README's options table (source, items, minLength, updater, matcher, sorter, highlighter, displayText, item, autoSelect, afterSelect). That table is the documentation; there is no other documentation and no changelog after 2016.
First use case — a Blade form with a server-side lookup
// routes/web.php
Route::get('/users/search', [UserSearchController::class, 'index'])->name('users.search');
// Controller
public function index(Request $request)
{
return User::query()
->where('name', 'like', $request->string('q').'%')
->orderBy('name')
->limit(10)
->get(['id', 'name']); // plain array of objects — no wrapping key
}
<input type="text" id="user-search" class="form-control" autocomplete="off" placeholder="Search users…">
<input type="hidden" name="user_id" id="user-id">
<script>
$(function () {
$('#user-search').typeahead({
minLength: 2,
items: 10,
displayText: function (item) { return item.name },
source: function (query, process) {
$.get('{{ route('users.search') }}', { q: query }, function (data) {
process(data) // must be called, even with []
})
},
afterSelect: function (item) {
$('#user-id').val(item.id)
}
})
$('#user-search').on('input', function () { $('#user-id').val('') })
})
</script>
That's the whole loop: type → GET → process(arrayOfObjects) → displayText renders → afterSelect stores the ID.
Debounce inside source, not outside it. The plugin fires source on every keystroke above minLength; there is no built-in debounce option. Wrap it yourself and abort the in-flight request — out-of-order AJAX responses otherwise overwrite newer results:
let xhr = null, timer = null
source: function (query, process) {
clearTimeout(timer)
timer = setTimeout(function () {
if (xhr) xhr.abort()
xhr = $.get(url, { q: query }, function (data) { process(data) })
}, 150)
}
Hidden-ID field is the canonical pattern. updater controls the text written into the input; afterSelect fires after selection and is the right hook for side effects (setting a hidden user_id, filling sibling fields, enabling a submit button). Returning a value from updater replaces the input text — return item.name there and never the ID, or users see raw IDs.
Custom item markup with item. Useful for avatars, email addresses, badges:
item: function (item) {
return '<a class="dropdown-item d-flex align-items-center">'
+ '<img src="' + item.avatar + '" width="20" class="me-2">'
+ '<span>' + $('<div>').text(item.name).html() + '</span></a>'
}
Escape anything user-controlled — the plugin injects the return value with .html().
Local arrays for small, static sets (countries, statuses, tags) — skip the HTTP round-trip entirely:
$('#tag').typeahead({ source: ['urgent', 'billing', 'bug', 'feature'] })
Reusable initializer driven by data attributes, so Blade partials don't each ship a <script>:
<input data-typeahead data-url="{{ route('users.search') }}" data-target="#user-id">
$('[data-typeahead]').each(function () {
const $el = $(this)
$el.typeahead({ minLength: 2, displayText: i => i.name, source: (q, process) =>
$.getJSON($el.data('url'), { q }, process),
afterSelect: i => $($el.data('target')).val(i.id)
})
})
Livewire / Turbo. DOM morphing destroys the plugin's state. Mark the wrapper wire:ignore, and re-init on livewire:navigated / turbo:load, not just DOMContentLoaded. Dispatching $dispatch('user-selected', { id }) from afterSelect is cleaner than mutating hidden inputs across framework boundaries.
"Show options on focus" isn't a feature — minLength: 1 means a click alone does nothing. Set minLength: 0 and make the endpoint tolerate q='', or trigger the fetch manually on focus.
autoSelect: false if Enter should submit the form rather than grab the top suggestion. Default behavior highlights the first item, which surprises users in search-then-submit forms.
Multi-field dependency chains (country → city): in the first field's afterSelect, clear and re-typeahead('destroy')-then-reinit the second, or store the chosen parent in a closure the second field's source reads. Avoid passing state through DOM text.
Unmaintained since 2016 — treat that as the headline risk. No ESM build, no TypeScript types, no accessibility work, no Bootstrap 5 awareness. For greenfield work, Tom Select or Algolia Autocomplete are better investments; choose this one when you want zero dependencies beyond jQuery and a tiny surface area. It still works fine on jQuery 3.7.
$.fn.typeahead collides with Twitter's typeahead.js and with Bootstrap 2's bundled plugin. If another package in your bundle also defines it, whichever loads last wins and the loser's options silently no-op. Grep typeahead across package.json before debugging anything weird.
Bootstrap 4/5 styling breaks the menu. The plugin emits <ul class="typeahead dropdown-menu"><li><a>…</a></li></ul> — BS4+ expects .dropdown-item on the anchor, so you get unstyled, padded-wrong rows. Minimal shim:
.typeahead.dropdown-menu { width: 100%; max-height: 320px; overflow-y: auto; }
.typeahead.dropdown-menu > li > a { display: block; padding: .375rem .75rem; clear: both; white-space: nowrap; }
.typeahead.dropdown-menu > li.active > a { background: var(--bs-primary); color: #fff; }
Clipping inside modals and scroll containers. The menu is absolutely positioned within the input's positioning context and will be cut off by overflow: hidden on any ancestor (.modal-body, .table-responsive, cards). Fixes: give the input's immediate wrapper position: relative, remove the offending overflow, or reparent the menu — but reparenting loses the plugin's own offset math, so prefer the CSS route.
Always call process(), even on empty results. If the callback never fires (error path, aborted request, a return response()->json(null)), later queries can leave the widget stuck with no menu. Do process([]) in every terminal branch, including $.get().fail(...).
Return a bare JSON array. Laravel's paginated responses (data: [...]) and resource()->collection() wrappers will not work — process() expects the array itself. ->get(['id','name']) or ->map(fn ($u) => [...])->values() before returning.
matcher, sorter, highlighter are irrelevant for server-side sources. The server already filtered and ordered; leave them alone. For local arrays, highlighter wraps matches in <strong>, which is the default and usually fine — but it operates on the display string, so objects need displayText before highlighting behaves.
Vite + jQuery global order is the #1 setup failure. Symptom: $(...).typeahead is not a function in production only. The window.jQuery = $ assignment must execute before the plugin module is evaluated; if you split vendor chunks, add a tiny jquery-global.js entry that every other entry imports first.
CSRF: source uses GET, so you're unaffected. If you POST, add $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } }) — the plugin has no header options of its own.
No teardown API worth relying on. For SPA/livewire re-inits, the reliable path is: remove the DOM node, insert a fresh one, re-init. Also .off('input') your own hand-attached handlers, which the plugin won't clean up.
Accessibility is on you. The rendered input has no role="combobox", aria-expanded, or aria-activedescendant, and no screen-reader announcements. If WCAG matters, add role="combobox" aria-autocomplete="list" aria-controls="…" to the input manually and maintain a visually hidden live region from afterSelect — or pick a different library.
Debugging tip: because everything is keyup-driven, log inside source (console.log(query, data.length)) before inspecting CSS. Nine out of ten "it doesn't show" reports are an empty array, a missing displayText on objects, or a swallowed error in the GET.
How can I help you explore Laravel packages today?