SmartSearchInput
A search-as-you-type component with debounced AJAX requests, multi-select badge display, keyboard navigation, pre-selected items, and a full JavaScript public API. Point it at any endpoint that returns [{id, name}] and it handles the rest.
<script src="/static/resources/js/components/smart_search_input.js"></script>
Live Playground
Search books from the Ice & Fire API. Adjust controls to see every attribute in action.
Async Search
Set search-url to any endpoint. The component appends ?q=<query> and expects a JSON array back. Typing is debounced by search-delay ms (default 300).
<smart-search-input label="Assign To" placeholder="Type a name..." search-url="/api/characters/" input-name="character_id" min-chars="2" max-results="6" badge-variant="primary" show-badges allow-remove ></smart-search-input>
search-delay ms (default 300ms)# Django view — what your endpoint should return from django.http import JsonResponse def search_characters(request): q = request.GET.get('q', '') results = Character.objects.filter( name__icontains=q ).values('id', 'name')[:10] # Component reads: item.name (or item.title or item.label) return JsonResponse(list(results), safe=False)
Pre-selected Items
Pass a JSON array to pre-selected to populate badges on load. Perfect for edit forms where you need to show existing selections.
<!-- Edit form: pre-populate existing selections --> <smart-search-input label="Selected Tags" placeholder="Search more..." search-url="/api/tags/" input-name="tag_ids" badge-variant="success" show-badges allow-remove pre-selected='[ {"id":1,"name":"Django"}, {"id":2,"name":"Python"}, {"id":3,"name":"REST API"} ]' ></smart-search-input> <!-- Or set programmatically from Django template --> pre-selected=''
Badge Variants
badge-variant maps to Bootstrap badge classes: bg-{variant}. All standard Bootstrap variants are supported.
<!-- secondary (default) --> <smart-search-input badge-variant="secondary" ... ></smart-search-input> <!-- primary --> <smart-search-input badge-variant="primary" ... ></smart-search-input> <!-- success / warning / danger / info --> <smart-search-input badge-variant="success" ... ></smart-search-input>
Edit & Remove
allow-remove adds a × button to each badge. allow-edit + edit-url-template adds an Edit link that opens the item's edit page — {id} in the template is replaced with the item's id.
<smart-search-input label="Team Members" search-url="/api/users/" input-name="member_ids" badge-variant="info" show-badges allow-remove allow-edit edit-url-template="/admin/users/{id}/edit/" pre-selected='[{"id":1,"name":"Alice Johnson"}]' ></smart-search-input> <!-- {id} is replaced with each item's actual id Edit link opens: /admin/users/1/edit/ -->
Custom Search Handler
Need custom filtering logic, local data, or a non-standard API? Set data-onsearch to a global function name. Return an array of {id, name} objects — the component renders them exactly as it would AJAX results.
<smart-search-input label="Search Skills" placeholder="Type to filter..." input-name="skill_ids" badge-variant="warning" show-badges allow-remove data-onsearch="localSkillSearch" min-chars="1" ></smart-search-input> // JS: return [{id, name}] — sync or async both work const SKILLS = [ {id:1,name:"Python"}, {id:2,name:"JavaScript"}, {id:3,name:"Django"}, // ... ]; function localSkillSearch(query) { return SKILLS.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) ); } // Async version also works: async function localSkillSearch(query) { const res = await fetch(`/api/skills?q=${query}`); return res.json(); // must return [{id, name}] }
Attributes
| Attribute | Type | Description | Default |
|---|---|---|---|
| label | string | Label displayed above the search input | Search Items |
| placeholder | string | Placeholder text inside the search input | Search and add items... |
| search-url | string | AJAX endpoint URL. Component appends ?q=<query> and expects [{id, name}] |
— |
| input-name | string | The name attribute of the hidden inputs created for each selected item. Use this to retrieve values server-side. |
selected_items |
| min-chars | number | Minimum characters before a search request fires | 2 |
| search-delay | number | Debounce delay in milliseconds between keystrokes and the request | 300 |
| max-results | number | Maximum number of results shown in the dropdown | 10 |
| show-badges | boolean | Render selected items as visible badge chips below the input | false |
| allow-remove | boolean | Adds a × button to each badge so users can deselect items | false |
| allow-edit | boolean | Adds an Edit link to each badge. Requires edit-url-template. |
false |
| edit-url-template | string | URL pattern for the edit link. {id} is replaced with the item's id. Example: /admin/users/{id}/edit/ |
— |
| badge-variant | string | Bootstrap badge colour. Maps to bg-{variant}. Options: secondary, primary, success, info, warning, danger |
secondary |
| pre-selected | JSON | Array of items to pre-select on load. Format: [{id, name}, ...]. Perfect for edit forms. |
— |
| no-results-text | string | Message shown in the dropdown when the API returns an empty array | No items found! |
| error-text | string | Message shown when the fetch request fails | Error searching items |
| data-onsearch | string | Name of a global function to call instead of the AJAX fetch. Receives the query string, must return [{id, name}] (sync or async). |
— |
| data-onselect | string | Global function called when an item is selected. Args: (item, allSelected) |
— |
| data-onremove | string | Global function called when an item badge is removed. Args: (item, allSelected) |
— |
| data-onerror | string | Global function called when the fetch request throws. Args: (error) |
— |
Events & Hooks
Two custom DOM events are dispatched. Both bubble. You can also use the attribute hooks for simpler one-liner callbacks.
const search = document.querySelector('smart-search-input'); // Fires when a result is clicked and added as a badge search.addEventListener('itemSelected', (e) => { console.log('Item selected:', e.detail.item); // e.detail.item → {id, name, ...} // e.detail.allSelected → [{id, name}, ...] }); // Fires when a badge × button is clicked search.addEventListener('itemRemoved', (e) => { console.log('Item removed:', e.detail.item); console.log('Remaining:', e.detail.allSelected); }); // ── Or use attribute hooks on the element ── <smart-search-input data-onselect="handleSelect" data-onremove="handleRemove" data-onerror="handleError" ></smart-search-input> function handleSelect(item, allSelected) { console.log('Selected:', item.name, '| Total:', allSelected.length); } function handleRemove(item, allSelected) { console.log('Removed:', item.name); } function handleError(error) { console.error('Search failed:', error.message); }
| Event | detail.item | detail.allSelected | When |
|---|---|---|---|
| itemSelected | {id, name, …} | [{id, name}, …] | User clicks a dropdown result |
| itemRemoved | {id, name, …} | [{id, name}, …] | User clicks × on a badge |
Public API
All methods are available on the element reference. Use them to read or mutate selection state from your own JS.
const el = document.querySelector('smart-search-input'); // ── Read ────────────────────────────────────── // Returns array of selected item objects el.getSelectedItems(); // → [{id:"1", name:"Django"}, {id:"2", name:"Python"}] // Returns array of selected item id strings el.getSelectedIds(); // → ["1", "2"] // ── Mutate ──────────────────────────────────── // Remove all selected items (badges + hidden inputs) el.clearSelected(); // Replace entire selection with new set el.setSelectedItems([ {id: 5, name: "React"}, {id: 6, name: "TypeScript"}, ]); // Add a single item programmatically el.addSelectedItem({id: 7, name: "Docker"}); // Remove a single item by id el.removeSelectedItem("5"); // Focus the search input el.focus();
| Method | Returns | Description |
|---|---|---|
| getSelectedItems() | Array | All selected item objects [{id, name, …}] |
| getSelectedIds() | Array | All selected item id strings |
| clearSelected() | void | Clears all selections, removes badges and hidden inputs |
| setSelectedItems(items) | void | Clears selection then sets to [{id, name}] array |
| addSelectedItem(item) | void | Adds one item {id, name}; no-op if already selected |
| removeSelectedItem(id) | void | Removes item by id string, dispatches itemRemoved |
| focus() | void | Focuses the inner text input |
Response Format
Your endpoint must return a flat JSON array. The component reads item.title then item.name then item.label as the display text — whichever exists first wins. item.description is shown as a subtitle if present.
// ✅ Minimum required [ { "id": 1, "name": "Django" }, { "id": 2, "name": "Python" } ] // ✅ With subtitle (shown under the title in dropdown) [ { "id": 1, "name": "Alice Johnson", "description": "Senior Developer" } ] // ✅ title field also works (checked before name) [ { "id": 1, "title": "A Game of Thrones" } ] // ✅ label field also works (checked last) [ { "id": 1, "label": "Option A" } ] // ❌ NOT supported — must be a flat array, not paginated { "results": [...], "count": 100 }