Feature: autocomplete/search suggestions (#72)
Basic autocomplete/search suggestion functionality added * Adds new GET and POST routes for '/autocomplete' that accept a string query and returns an array of suggestions * Adds new autoscript.js file for handling queries on the main page and results view * Updated requests class to include autocomplete method * Updated opensearch template to handle search suggestions * Added header template to allow for autocomplete on results view * Updated readme to mention autocomplete featurepull/85/head
parent
3dbe51e9e7
commit
21012f5265
@ -0,0 +1,55 @@
|
|||||||
|
header {
|
||||||
|
font-family: Roboto,HelveticaNeue,Arial,sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: #3C4043;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-link, .logo-letter {
|
||||||
|
text-decoration: none !important;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 2px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logo {
|
||||||
|
font: 22px/36px Futura, Arial, sans-serif;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-div {
|
||||||
|
letter-spacing: -1px;
|
||||||
|
text-align: center;
|
||||||
|
font: 22pt Futura, Arial, sans-serif;
|
||||||
|
padding: 10px 0 5px 0;
|
||||||
|
height: 37px;
|
||||||
|
font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-div {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
box-shadow: 0 1px 6px rgba(32, 33, 36, 0.18);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
height: 39px;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
background: none;
|
||||||
|
margin: 2px 4px 2px 8px;
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
flex: 1;
|
||||||
|
height: 35px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
.autocomplete {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #d4d4d4;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: none;
|
||||||
|
z-index: 99;
|
||||||
|
|
||||||
|
/*position the autocomplete items to be the same width as the container:*/
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items div {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-items div:hover {
|
||||||
|
background-color: #e9e9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete-active {
|
||||||
|
background-color: #685e79 !important;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
const handleUserInput = searchBar => {
|
||||||
|
let xhrRequest = new XMLHttpRequest();
|
||||||
|
xhrRequest.open("POST", "/autocomplete");
|
||||||
|
xhrRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||||
|
xhrRequest.onload = function() {
|
||||||
|
if (xhrRequest.readyState === 4 && xhrRequest.status !== 200) {
|
||||||
|
alert("Error fetching autocomplete results");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill autocomplete with fetched results
|
||||||
|
let autocompleteResults = JSON.parse(xhrRequest.responseText);
|
||||||
|
autocomplete(searchBar, autocompleteResults[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhrRequest.send('q=' + searchBar.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const autocomplete = (searchInput, autocompleteResults) => {
|
||||||
|
let currentFocus;
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", function () {
|
||||||
|
let autocompleteList, autocompleteItem, i, val = this.value;
|
||||||
|
closeAllLists();
|
||||||
|
|
||||||
|
if (!val || !autocompleteResults) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFocus = -1;
|
||||||
|
autocompleteList = document.createElement("div");
|
||||||
|
autocompleteList.setAttribute("id", this.id + "-autocomplete-list");
|
||||||
|
autocompleteList.setAttribute("class", "autocomplete-items");
|
||||||
|
this.parentNode.appendChild(autocompleteList);
|
||||||
|
|
||||||
|
for (i = 0; i < autocompleteResults.length; i++) {
|
||||||
|
if (autocompleteResults[i].substr(0, val.length).toUpperCase() === val.toUpperCase()) {
|
||||||
|
autocompleteItem = document.createElement("div");
|
||||||
|
autocompleteItem.innerHTML = "<strong>" + autocompleteResults[i].substr(0, val.length) + "</strong>";
|
||||||
|
autocompleteItem.innerHTML += autocompleteResults[i].substr(val.length);
|
||||||
|
autocompleteItem.innerHTML += "<input type=\"hidden\" value=\"" + autocompleteResults[i] + "\">";
|
||||||
|
autocompleteItem.addEventListener("click", function () {
|
||||||
|
searchInput.value = this.getElementsByTagName("input")[0].value;
|
||||||
|
closeAllLists();
|
||||||
|
document.getElementById("search-form").submit();
|
||||||
|
});
|
||||||
|
autocompleteList.appendChild(autocompleteItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("keydown", function (e) {
|
||||||
|
let suggestion = document.getElementById(this.id + "-autocomplete-list");
|
||||||
|
if (suggestion) suggestion = suggestion.getElementsByTagName("div");
|
||||||
|
if (e.keyCode === 40) { // down
|
||||||
|
currentFocus++;
|
||||||
|
addActive(suggestion);
|
||||||
|
} else if (e.keyCode === 38) { //up
|
||||||
|
currentFocus--;
|
||||||
|
addActive(suggestion);
|
||||||
|
} else if (e.keyCode === 13) { // enter
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentFocus > -1) {
|
||||||
|
if (suggestion) suggestion[currentFocus].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addActive = suggestion => {
|
||||||
|
if (!suggestion || !suggestion[currentFocus]) return false;
|
||||||
|
removeActive(suggestion);
|
||||||
|
|
||||||
|
if (currentFocus >= suggestion.length) currentFocus = 0;
|
||||||
|
if (currentFocus < 0) currentFocus = (suggestion.length - 1);
|
||||||
|
|
||||||
|
suggestion[currentFocus].classList.add("autocomplete-active");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeActive = suggestion => {
|
||||||
|
for (let i = 0; i < suggestion.length; i++) {
|
||||||
|
suggestion[i].classList.remove("autocomplete-active");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAllLists = el => {
|
||||||
|
let suggestions = document.getElementsByClassName("autocomplete-items");
|
||||||
|
for (let i = 0; i < suggestions.length; i++) {
|
||||||
|
if (el !== suggestions[i] && el !== searchInput) {
|
||||||
|
suggestions[i].parentNode.removeChild(suggestions[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close lists and search when user selects a suggestion
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
closeAllLists(e.target);
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,53 @@
|
|||||||
|
{% if mobile %}
|
||||||
|
<header>
|
||||||
|
<div class="bz1lBb">
|
||||||
|
<form class="Pg70bf" id="search-form" method="POST">
|
||||||
|
<a class="logo-link mobile-logo"
|
||||||
|
href="/"
|
||||||
|
style="display:flex; justify-content:center; align-items:center; color:#685e79; font-size:18px; ">
|
||||||
|
<span class="V6gwVd">Wh</span><span class="iWkuvd">o</span><span class="cDrQ7">o</span><span
|
||||||
|
class="V6gwVd">g</span><span class="ntlR9">l</span><span
|
||||||
|
class="iWkuvd tJ3Myc">e</span>
|
||||||
|
</a>
|
||||||
|
<div class="H0PQec" style="width: 100%;">
|
||||||
|
<div class="sbc esbc autocomplete">
|
||||||
|
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
||||||
|
spellcheck="false" type="text" value="{{ q }}">
|
||||||
|
<div class="sc"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% else %}
|
||||||
|
<header>
|
||||||
|
<div class="logo-div">
|
||||||
|
<a class="logo-link" href="/">
|
||||||
|
<span class="V6gwVd logo-letter">Wh</span><span class="iWkuvd logo-letter">o</span><span
|
||||||
|
class="cDrQ7 logo-letter">o</span><span class="V6gwVd logo-letter">g</span><span
|
||||||
|
class="ntlR9 logo-letter">l</span><span class="iWkuvd tJ3Myc logo-letter">e</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="search-div">
|
||||||
|
<form id="search-form" class="search-form" id="sf" method="POST">
|
||||||
|
<div class="autocomplete" style="width: 100%; flex: 1">
|
||||||
|
<div style="width: 100%; display: flex">
|
||||||
|
<input id="search-bar" autocapitalize="none" autocomplete="off" class="noHIxc" name="q"
|
||||||
|
spellcheck="false" type="text" value="{{ q }}">
|
||||||
|
<div class="sc"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const searchBar = document.getElementById("search-bar");
|
||||||
|
|
||||||
|
searchBar.addEventListener("keyup", function (event) {
|
||||||
|
if (event.keyCode !== 13) {
|
||||||
|
handleUserInput(searchBar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
@ -0,0 +1,12 @@
|
|||||||
|
def test_autocomplete_get(client):
|
||||||
|
rv = client.get('/autocomplete?q=green+eggs+and')
|
||||||
|
assert rv._status_code == 200
|
||||||
|
assert len(rv.data) >= 1
|
||||||
|
assert b'green eggs and ham' in rv.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_autocomplete_post(client):
|
||||||
|
rv = client.post('/autocomplete', data=dict(q='the+cat+in+the'))
|
||||||
|
assert rv._status_code == 200
|
||||||
|
assert len(rv.data) >= 1
|
||||||
|
assert b'the cat in the hat' in rv.data
|
Loading…
Reference in New Issue