Skip to content

Commit

Permalink
Add example of how to get suggestions via AJAX
Browse files Browse the repository at this point in the history
Including:
- displaying a loading state
- displaying errors when they occur
- debouncing the calls to avoid to much load for the server
  • Loading branch information
romaricpascal committed Jan 18, 2024
1 parent 24e5bce commit e5bfe6a
Show file tree
Hide file tree
Showing 2 changed files with 564 additions and 0 deletions.
306 changes: 306 additions & 0 deletions examples/ajax-source.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
<!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
// See: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#events
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>
Loading

0 comments on commit e5bfe6a

Please sign in to comment.