Skip to content

Commit 0405387

Browse files
Add ajax search to the autocomplete component
Fixes #544
1 parent 0af6e2f commit 0405387

File tree

3 files changed

+341
-42
lines changed

3 files changed

+341
-42
lines changed

docs/src/frontend/components/autocomplete.md

Lines changed: 224 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The Autocomplete component transforms a `<select>` element into an accessible, s
1010
-**Searchable** - Filter options as you type
1111
-**Free Type** - Allow custom values not in the list
1212
-**Multiple Selection** - Enhanced multi-select with tags
13+
-**AJAX Loading** - Load options dynamically from API endpoint with pagination
1314
-**Accent Insensitive** - Searches normalize accents (é → e)
1415
-**Dynamic Content** - Works with AJAX-loaded selects
1516
-**Mutation Observer** - Syncs with programmatic changes to original select
@@ -70,6 +71,187 @@ Free type means that you allow the user to add an option that is not in the list
7071
</select>
7172
```
7273

74+
## AJAX Loading
75+
76+
The autocomplete component can load options dynamically from an API endpoint using the `data-ajax-url` attribute. This is ideal for large datasets, search-as-you-type functionality, or data that changes frequently.
77+
78+
### Basic AJAX Example
79+
80+
```html
81+
<select name="newsSelect" data-autocomplete data-ajax-url="/api/options">
82+
<option value="">Search options...</option>
83+
</select>
84+
```
85+
86+
### API Response Format
87+
88+
Your API endpoint must return JSON in this exact format:
89+
90+
```json
91+
{
92+
"data": [
93+
{ "value": 1, "option": "News Title 1" },
94+
{ "value": 2, "option": "News Title 2" },
95+
{ "value": 3, "option": "News Title 3" }
96+
],
97+
"pagination": {
98+
"page": 1,
99+
"perPage": 20,
100+
"total": 50,
101+
"totalPages": 3
102+
}
103+
}
104+
```
105+
106+
**Required fields:**
107+
108+
- `data` - Array of option objects
109+
- `value` - The option value (number or string, converted to string internally)
110+
- `option` - The display text for the option
111+
- `pagination` - Pagination metadata
112+
- `page` - Current page number
113+
- `perPage` - Number of items per page
114+
- `total` - Total number of items available
115+
- `totalPages` - Total number of pages
116+
117+
### How AJAX Loading Works
118+
119+
1. **Initial Load**: Component fetches first page from `data-ajax-url` on initialization
120+
2. **Search**: As user types, component sends `?q={searchTerm}&page=1` to API
121+
3. **Pagination**: As user scrolls near bottom of list (200px threshold), next page loads automatically
122+
4. **Merging**: New options are appended to existing list for infinite scroll experience
123+
5. **Selected Options**: Selected options are preserved and excluded from duplicate loading
124+
125+
### API Parameters
126+
127+
The component automatically sends these query parameters:
128+
129+
| Parameter | Type | Description | Example |
130+
| --------- | ------ | ------------------------------------ | ------------- |
131+
| `q` | string | Search query (optional, when typing) | `?q=breaking` |
132+
| `page` | number | Page number for pagination | `?page=2` |
133+
134+
### Example: Craft CMS API Endpoint
135+
136+
Create a Twig template at `/templates/api/news.twig`:
137+
138+
```twig
139+
{%- header "Content-Type: application/json" -%}
140+
{%- set searchQuery = craft.app.request.getParam('q') -%}
141+
{%- set perPage = craft.app.request.getParam('perPage', 20) -%}
142+
{%- set page = craft.app.request.getParam('page', 1) -%}
143+
{%- set newsQuery = craft.entries()
144+
.section('news')
145+
.orderBy('title ASC') -%}
146+
{%- if searchQuery -%}
147+
{%- set newsQuery = newsQuery.search('title:*' ~ searchQuery ~ '*') -%}
148+
{%- endif -%}
149+
{%- set totalEntries = newsQuery.count() -%}
150+
{%- set newsEntries = newsQuery.offset((page - 1) * perPage).limit(perPage).all() -%}
151+
{
152+
"data": [
153+
{%- for entry in newsEntries -%}
154+
{"value": {{ entry.id }}, "option": {{ entry.title|json_encode|raw }}}
155+
{%- if not loop.last -%},{%- endif -%}
156+
{%- endfor -%}
157+
],
158+
"pagination": {
159+
"page": {{ page }},
160+
"perPage": {{ perPage }},
161+
"total": {{ totalEntries }},
162+
"totalPages": {{ (totalEntries / perPage)|round(0, 'ceil') }}
163+
}
164+
}
165+
```
166+
167+
Add route in `config/routes.php`:
168+
169+
```php
170+
return [
171+
'api/news.json' => ['template' => 'api/news'],
172+
];
173+
```
174+
175+
### AJAX with Pre-selected Options
176+
177+
You can combine AJAX loading with pre-selected options in the select element:
178+
179+
```html
180+
<select name="newsSelect" data-autocomplete data-ajax-url="/api/news.json" class="border-1">
181+
<option value="">Search news...</option>
182+
<option value="42" selected>Pre-selected News Article</option>
183+
</select>
184+
```
185+
186+
The component will:
187+
188+
1. Load AJAX options from API
189+
2. Detect the pre-selected option from the select element
190+
3. Show it in the input field (single select) or as a tag (multi-select)
191+
4. Exclude it from duplicate loading when paginating
192+
193+
### Infinite Scroll Pagination
194+
195+
When using AJAX, the dropdown list supports infinite scroll:
196+
197+
- **Scroll Trigger**: When user scrolls within 200px of bottom
198+
- **Automatic Loading**: Next page fetches and appends automatically
199+
- **No Duplicates**: Already selected options are excluded
200+
- **Page Tracking**: Component tracks current page and prevents duplicate requests
201+
- **End Detection**: Stops loading when `page >= totalPages`
202+
203+
::: tip Pagination Performance
204+
For best performance:
205+
206+
- Keep `perPage` between 10-50 items
207+
- Use server-side indexing for fast searches
208+
- Consider caching frequently accessed pages
209+
- Return consistent `totalPages` for accurate scroll detection
210+
:::
211+
212+
### AJAX with Multiple Select
213+
214+
```html
215+
<select name="newsMultiple" data-autocomplete data-ajax-url="/api/news.json" multiple>
216+
<option value="">Search and select multiple news...</option>
217+
</select>
218+
```
219+
220+
Behavior:
221+
222+
- Selected items appear as tags
223+
- Tags are excluded from API results (no duplicates)
224+
- Search resets to page 1
225+
- Pagination continues to work as user scrolls
226+
227+
### AJAX Error Handling
228+
229+
The component does not currently implement explicit error handling. If the API request fails:
230+
231+
- Console errors will appear
232+
- Options list will remain empty or show previous results
233+
- No user-facing error message displayed
234+
235+
**Best practices:**
236+
237+
- Ensure API endpoint is reliable
238+
- Test API response format carefully
239+
- Monitor console for network errors
240+
- Consider adding server-side logging
241+
242+
::: warning API Format Required
243+
The AJAX feature **requires** the exact JSON format shown above. Incorrect format will cause the component to fail silently or throw JavaScript errors.
244+
:::
245+
246+
::: tip Combining with Static Options
247+
You can combine AJAX options with static `<option>` elements. The component loads both:
248+
249+
1. First, AJAX options from the API
250+
2. Then, static options from the select element
251+
252+
This is useful for having a "Other" or "None" option alongside dynamic data.
253+
:::
254+
73255
## Required Attributes
74256

75257
| Attribute | Required | Description |
@@ -83,6 +265,7 @@ Free type means that you allow the user to add an option that is not in the list
83265
| `multiple` | Native HTML attribute for multiple selection. Component adds tag-style UI for selected items. |
84266
| `free-type` | Allows users to enter custom values not in the option list. Creates a new option dynamically. |
85267
| `disabled` | Native HTML attribute. Component observes changes and updates UI accordingly via MutationObserver. |
268+
| `data-ajax-url` | URL to fetch options from via AJAX. Response must match the API format (see AJAX Loading section). |
86269
| `data-autocomplete-reference` | CSS selector for a different element to append the dropdown to (useful for modals or overflow containers). |
87270

88271
::: tip Free Type Usage
@@ -97,17 +280,20 @@ With `free-type`, users can type any value and it will be added to the select as
97280
2. **Validates** element is a `<select>` (others are skipped)
98281
3. **Removes** `data-autocomplete` attribute
99282
4. **Adds** `data-autocomplete-init` with unique path identifier
100-
5. **Creates** UI structure:
283+
5. **Detects** `data-ajax-url` attribute for AJAX mode
284+
6. **Creates** UI structure:
101285
- Wrapper div (`.autocomplete`)
102286
- Input field for searching
103287
- Dropdown list (hidden by default)
104288
- Status div for screen readers
105289
- Dropdown icon button
106-
6. **Hides** original `<select>` (sets `aria-hidden="true"`, `tabindex="-1"`, adds `hidden` class)
107-
7. **Copies** classes from `<select>` to new autocomplete element
108-
8. **Sets up** MutationObserver to watch for changes to original select
109-
9. **Parses** options and selected values
110-
10. **Supports** dynamic content via `DOMHelper.onDynamicContent`
290+
7. **Hides** original `<select>` (sets `aria-hidden="true"`, `tabindex="-1"`, adds `hidden` class)
291+
8. **Copies** classes from `<select>` to new autocomplete element
292+
9. **Sets up** MutationObserver to watch for changes to original select
293+
10. **Loads** options (from AJAX if URL provided, or from static options)
294+
11. **Sets up** scroll listener for infinite pagination (AJAX mode only)
295+
12. **Parses** selected values from both AJAX data and static options
296+
13. **Supports** dynamic content via `DOMHelper.onDynamicContent`
111297

112298
### User Interaction Flow
113299

@@ -204,12 +390,14 @@ The autocomplete wrapper automatically receives all classes from the original `<
204390

205391
## Search Behavior
206392

393+
### Static Options
394+
207395
The component filters options using **accent-insensitive** matching:
208396

209397
```javascript
210398
// Both of these will match "José":
211-
'jose'; // User types without accent
212-
'José'; // Option in list
399+
"jose"; // User types without accent
400+
"José"; // Option in list
213401

214402
// Normalizes: é → e, ñ → n, ü → u, etc.
215403
```
@@ -221,6 +409,25 @@ The component filters options using **accent-insensitive** matching:
221409
3. Matches against both original and normalized option text
222410
4. Shows all matching options
223411

412+
### AJAX Options
413+
414+
When `data-ajax-url` is set, search behavior changes:
415+
416+
1. User types in input field
417+
2. Component sends `?q={searchTerm}&page=1` to API endpoint
418+
3. API performs server-side search and returns matching results
419+
4. Component replaces current options with search results
420+
5. Selected options are preserved and merged with results
421+
6. Pagination resets to page 1 on each new search
422+
7. Infinite scroll works with search results
423+
424+
**AJAX search characteristics:**
425+
426+
- Server-side filtering (implement in your API)
427+
- Resets to page 1 on every keystroke
428+
- Tracks search term separately for pagination
429+
- Empty search term loads all results (page 1)
430+
224431
## Accessibility
225432

226433
### ARIA Implementation
@@ -282,12 +489,12 @@ A **MutationObserver** watches the original `<select>` for:
282489

283490
```javascript
284491
// These changes are automatically detected:
285-
selectElement.value = '5';
492+
selectElement.value = "5";
286493
selectElement.selectedIndex = 2;
287494
selectElement.options[0].selected = true;
288495

289496
// Trigger jschange event to update autocomplete UI:
290-
selectElement.dispatchEvent(new Event('jschange'));
497+
selectElement.dispatchEvent(new Event("jschange"));
291498
```
292499

293500
::: tip jschange Event
@@ -353,19 +560,19 @@ Listen for these events on the **original `<select>` element**:
353560
| `jschange` | ❌ No | Custom event you can dispatch to sync the UI |
354561

355562
```javascript
356-
const selectElement = document.querySelector('[data-autocomplete-init]');
563+
const selectElement = document.querySelector("[data-autocomplete-init]");
357564

358-
selectElement.addEventListener('autocompleteShowMenu', () => {
359-
console.log('Dropdown opened');
565+
selectElement.addEventListener("autocompleteShowMenu", () => {
566+
console.log("Dropdown opened");
360567
});
361568

362-
selectElement.addEventListener('change', (e) => {
363-
console.log('Selected value:', e.target.value);
569+
selectElement.addEventListener("change", (e) => {
570+
console.log("Selected value:", e.target.value);
364571
});
365572

366573
// Programmatic change:
367-
selectElement.value = '5';
368-
selectElement.dispatchEvent(new Event('jschange')); // Syncs autocomplete UI
574+
selectElement.value = "5";
575+
selectElement.dispatchEvent(new Event("jschange")); // Syncs autocomplete UI
369576
```
370577

371578
## Language Support

frontend/css/site/main.css

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@
3434
/*******************
3535
* Uncomment the components you use in the project!
3636
********************/
37-
/* @source "../../js/components-base/ajaxSearch.component.ts"; */
38-
/* @source "../../js/components-base/autocomplete.component.ts"; */
39-
/* @source "../../js/components-base/chip.component.ts"; */
40-
/* @source "../../js/components-base/modal.component.ts"; */
37+
/* @source "../../js/components-core/ajaxSearch.component.ts"; */
38+
/* @source "../../js/components-core/autocomplete.component.ts"; */
39+
/* @source "../../js/components-core/chip.component.ts"; */
40+
/* @source "../../js/components-core/modal.component.ts"; */
4141
/* @source "../../js/plugins/modal"; */
42-
/* @source "../../js/components-base/videoBackground.component.ts"; */
43-
/* @source "../../js/components-base/videoToggle.component.ts"; */
42+
/* @source "../../js/components-core/videoBackground.component.ts"; */
43+
/* @source "../../js/components-core/videoToggle.component.ts"; */
4444
/* @source "../../js/plugins/validation/passwordStrength.component.ts"; */
4545

4646
@utility container {

0 commit comments

Comments
 (0)