Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example of how to get suggestions via AJAX #632

Merged
merged 3 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions examples/ajax-source.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Autocomplete AJAX source 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 source 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.toLowerCase().indexOf(query.toLowerCase()) !== -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 = new AbortController();

return fn(...args, {signal: abortController.signal})
.then(result => {
abortController = null;
return result;
}, error => {
// 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') {
abortController = null;
throw error;
}
})
}
}

// Debounces the given function so it only gets executed after a specific delay
function debounce(fn, wait) {
let timeout
return function (...args) {
return new Promise(resolve => {
clearTimeout(timeout)

const later = function () {
timeout = null
resolve(fn(...args))
}
timeout = setTimeout(later, wait)
})
}
}


// 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>
6 changes: 4 additions & 2 deletions examples/form-single.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Autocomplete form example</title>
<title>Accessible Autocomplete single field form example</title>
<style>
/* Example page specific styling. */
html {
Expand Down Expand Up @@ -55,7 +55,9 @@
</head>
<body>
<main>
<h1>Accessible Autocomplete form example</h1>
<h1>Accessible Autocomplete single field form example</h1>

<p><a href="ajax-source.html">Another HTML form example, simulating an AJAX request</a></p>

<div class="submitted submitted--hidden">
<p>You submitted:</p>
Expand Down
Loading