-
Notifications
You must be signed in to change notification settings - Fork 149
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add example of how to get suggestions via AJAX
Including: - displaying a loading state - displaying errors when they occur - debouncing the calls to avoid to much load for the server
- Loading branch information
1 parent
24e5bce
commit 70cb310
Showing
2 changed files
with
563 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,305 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="utf-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
<title>Accessible Autocomplete AJAX example</title> | ||
<style> | ||
/* Example page specific styling. */ | ||
html { | ||
color: #111; | ||
background: #FFF; | ||
font-family: -apple-system, BlinkMacSystemFont, 'avenir next', avenir, 'helvetica neue', helvetica, ubuntu, roboto, noto, 'segoe ui', arial, sans-serif; | ||
font-size: 16px; | ||
line-height: 1.5; | ||
} | ||
|
||
body { | ||
padding-left: 1rem; | ||
padding-right: 1rem; | ||
} | ||
|
||
h1, h2, h3, h4, h5, h6 { | ||
line-height: normal; | ||
} | ||
|
||
label { | ||
display: block; | ||
margin-bottom: .5rem; | ||
} | ||
|
||
code { | ||
padding-left: .5em; | ||
padding-right: .5em; | ||
background: #EFEFEF; | ||
font-weight: normal; | ||
font-family: monospace; | ||
} | ||
|
||
main { | ||
max-width: 40em; | ||
margin-left: auto; | ||
margin-right: auto; | ||
} | ||
|
||
.autocomplete-wrapper { | ||
max-width: 20em; | ||
margin-bottom: 4rem; | ||
} | ||
|
||
.submitted--hidden { | ||
display: none; | ||
} | ||
</style> | ||
<link rel="stylesheet" href="../dist/accessible-autocomplete.min.css"> | ||
</head> | ||
<body> | ||
<main> | ||
<h1>Accessible Autocomplete AJAX example</h1> | ||
|
||
<div class="submitted submitted--hidden"> | ||
<p>You submitted:</p> | ||
<ul> | ||
<li><code>"last-location": <span class="submitted__last-location"></span></code></li> | ||
</ul> | ||
<hr /> | ||
</div> | ||
|
||
<form action="form-single.html" method="get"> | ||
<label for="last-location">What was the last location you visited?</label> | ||
<div class="autocomplete-wrapper"> | ||
</div> | ||
|
||
<button type="submit">Submit your answer</button> | ||
</form> | ||
</main> | ||
|
||
<script type="text/javascript" src="../dist/accessible-autocomplete.min.js"></script> | ||
<script type="text/javascript"> | ||
// Sending requests to a server means that when the autocomplete has no | ||
// result it may not be because there are no results, but because these | ||
// results are being fetched, or because an error happened. We can use the | ||
// function for internationalising the 'No results found' message to | ||
// provide a little more context to users. | ||
// | ||
// It'll rely on a `status` variable updated by the wrappers of the | ||
// function making the request (see thereafter) | ||
let status; | ||
function tNoResults() { | ||
if (status === 'loading') { | ||
return 'Loading suggestions...' | ||
} else if (status === 'error') { | ||
return 'Sorry, an error occurred' | ||
}else { | ||
return 'No results found' | ||
} | ||
} | ||
|
||
// The aim being to load suggestions from a server, we'll need a function | ||
// that does just that. This one uses `fetch`, but you could also use | ||
// XMLHttpRequest or whichever library is the most suitable to your | ||
// project | ||
// | ||
// For lack of a actual server able of doing computation our endpoint will | ||
// return the whole list of countries and we'll simulate the work of the | ||
// server client-side | ||
function requestSuggestions(query, fetchArgs = {}) { | ||
return fetch('./suggestions.json', fetchArgs) | ||
.then((response) => response.json()) | ||
} | ||
|
||
// We'll wrap that function multiple times, each enhancing the previous | ||
// wrapping to handle the the different behaviours necessary to | ||
// appropriately coordinate requests to the server and display feedback to | ||
// users | ||
const makeRequest = | ||
// Wrapping everything is the error handling to make sure it catches | ||
// errors from any of the other wrappers | ||
trackErrors( | ||
// Next up is tracking whether we're loading new results | ||
trackLoading( | ||
// To avoid overloading the server with potentially costly requests | ||
// as well as avoid wasting bandwidth while users are typing we'll | ||
// only send requests a little bit after they stop typing | ||
debounce( | ||
// Finally we want to cancel requests that are already sent, so | ||
// only the results of the last one update the UI This is the role | ||
// of the next two wrappers | ||
abortExisting( | ||
// That last one is for demo only, to simulate server behaviours | ||
// (latency, errors, filtering) on the client | ||
simulateServer( | ||
requestSuggestions | ||
) | ||
), | ||
250 | ||
) | ||
) | ||
); | ||
|
||
// We can then use the function we built and adapt it to the autocomplete | ||
// API encapsulating the adjustments specific to rendering the 'No result | ||
// found' message | ||
function source(query, populateResults) { | ||
// Start by clearing the results to ensure a loading message | ||
// shows when a the query gets updated after results have loaded | ||
populateResults([]) | ||
|
||
makeRequest(query) | ||
// Only update the results if an actual array of options get returned | ||
// allowing for `makeRequest` to avoid making updates to results being | ||
// already displayed by resolving to `undefined`, like when we're | ||
// aborting requests | ||
.then(options => options && populateResults(options)) | ||
// In case of errors, we need to clear the results so the accessible | ||
// autocomplate show its 'No result found' | ||
.catch(error => populateResults([])) | ||
} | ||
|
||
// And finally we can set up our accessible autocomplete | ||
const element = document.querySelector('.autocomplete-wrapper') | ||
const id = 'autocomplete-default' | ||
accessibleAutocomplete({ | ||
element: element, | ||
id: id, | ||
source: source, | ||
tNoResults: tNoResults, | ||
menuAttributes: { | ||
"aria-labelledby": id | ||
}, | ||
inputClasses: "govuk-input" | ||
}) | ||
|
||
//// | ||
// INTERNAL DETAILS | ||
//// | ||
|
||
// Technically, it'd be the server doing the filtering but for lack of | ||
// server, we're requesting the whole list and filter client-side. | ||
// Similarly, we'll use a specific query to trigger error for demo | ||
// purpose, which will be easier than going in the devtools and making the | ||
// request error We'll also simulate that the server takes a little time | ||
// to respond to make things more visible in the UI | ||
const SERVER_LATENCY = 2500; | ||
function simulateServer(fn) { | ||
return function(query, ...args) { | ||
return new Promise(resolve => { | ||
setTimeout(() => { | ||
const suggestions = fn(query, ...args) | ||
.then((response) => { | ||
if (query === 'trigger-error') { | ||
throw new Error('Custom error') | ||
} | ||
return response; | ||
}) | ||
.then(countries => { | ||
return countries.filter(country => country.indexOf(query) !== -1) | ||
}) | ||
|
||
resolve(suggestions) | ||
}, SERVER_LATENCY) | ||
}) | ||
} | ||
} | ||
|
||
// Debouncing limits the number of requests being sent | ||
// but does not guarantee the order in which the responses come in | ||
// Due to network and server latency, a response to an earlier request | ||
// may come back after a response to a later request | ||
// This keeps track of the AbortController of the last request sent | ||
// so it can be cancelled before sending a new one | ||
// | ||
// NOTE: If you're using `XMLHttpRequest`s or a custom library, | ||
// they'll have a different mechanism for aborting. You can either: | ||
// - adapt this function to store whatever object lets you abort in-flight requests | ||
// - or adapt your version of `requestSuggestion` to listen to the `signal` | ||
// that this function passes to the wrapped function and trigger | ||
// whichever API for aborting you have available | ||
let abortController; | ||
function abortExisting(fn) { | ||
return function(...args) { | ||
if (abortController) { | ||
abortController.abort(); | ||
abortController = null; | ||
} | ||
abortController = new AbortController(); | ||
return fn(...args, {signal: abortController.signal}) | ||
.then(result => { | ||
abortController = null; | ||
// CHECK: Is it worth keeping track of the controller specific to the request | ||
// and only returning result if it wasn't aborted, and otherwise throwing an error | ||
// with an 'AbortError' name to match `fetch` | ||
return result; | ||
}, error => { | ||
abortController = null; | ||
// Aborting requests will lead to `fetch` rejecting with an `abortError` | ||
// In that situation, that's something we expect, so we don't want to show a message | ||
// to users | ||
if (error.name !== 'AbortError') { | ||
throw error; | ||
} | ||
}) | ||
} | ||
} | ||
|
||
// Debounces the given function so it only gets executed after a specific delay | ||
function debounce(fn, wait, immediate) { | ||
let timeout | ||
return function () { | ||
const context = this | ||
const args = arguments | ||
const callNow = immediate && !timeout | ||
|
||
return new Promise(resolve => { | ||
const later = function () { | ||
timeout = null | ||
if (!immediate) resolve(fn.apply(context, args)) | ||
} | ||
clearTimeout(timeout) | ||
timeout = setTimeout(later, wait) | ||
if (callNow) resolve(fn.apply(context, args)) | ||
}) | ||
} | ||
} | ||
|
||
|
||
// Tracks the loading state so we can adapt the message being displayed to the user | ||
function trackLoading(fn) { | ||
return function(...args) { | ||
status = 'loading'; | ||
return fn(...args) | ||
.then(result => { | ||
status = null; | ||
return result | ||
}, error => { | ||
status = null; | ||
throw error | ||
}) | ||
} | ||
} | ||
|
||
// In a similar fashion, we can track errors happening, which will adjust the message | ||
function trackErrors(fn) { | ||
return function(...args) { | ||
return fn(...args) | ||
.catch(error => { | ||
status = 'error' | ||
throw error | ||
}) | ||
} | ||
} | ||
</script> | ||
|
||
<script> | ||
var queryStringParameters = window.location.search | ||
var previouslySubmitted = queryStringParameters.length > 0 | ||
if (previouslySubmitted) { | ||
var submittedEl = document.querySelector('.submitted') | ||
submittedEl.classList.remove('submitted--hidden') | ||
var params = new URLSearchParams(document.location.search.split('?')[1]) | ||
document.querySelector('.submitted__last-location').innerHTML = params.get('last-location') | ||
document.querySelector('.submitted__passport-location').innerHTML = params.get('passport-location') | ||
} | ||
</script> | ||
</body> | ||
</html> |
Oops, something went wrong.