Skip to content

Commit

Permalink
Refactor site search to use custom element
Browse files Browse the repository at this point in the history
  • Loading branch information
paulrobertlloyd committed Dec 12, 2023
1 parent c65c44c commit df1efbf
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 194 deletions.
13 changes: 0 additions & 13 deletions components/all.js

This file was deleted.

78 changes: 30 additions & 48 deletions components/site-search/_site-search.scss
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
// Site search using Accessible autocomplete
// below styles are based on the default accessible autocomplete style sheet

@function encode-hex($hex) {
// Turn colour into a string
$output: inspect($hex);
// Slice the '#' from the start of the string so we can add it back on encoded.
$output: str-slice($output, 2);
// Add the '#' back on the start, but as an encoded character for embedding.
@return "%23" + $output;
}
// Styles below are based on the default accessible autocomplete style sheet

$icon-size: 40px;

.app-site-search {
float: left;
margin-bottom: govuk-spacing(2);
position: relative;
width: 100%;
padding-bottom: govuk-spacing(1);
padding-top: govuk-spacing(1);

@include govuk-media-query($from: 900px) {
@include govuk-media-query($from: desktop) {
float: right;
margin: 0;
margin-top: -5px; // Negative margin to vertically align search in header
width: 300px;
}

.no-js & {
display: none;
&:defined {
position: relative;
width: 100%;

@include govuk-media-query($from: 900px) {
display: block;
@include govuk-media-query($from: desktop) {
margin-top: -10px;
width: 300px;
}
}
}

@include govuk-media-query($from: 900px) {
text-align: right;
}
.app-site-search__link {
@include govuk-link-style-inverse;

display: inline-block;
padding-bottom: govuk-spacing(1);
padding-top: govuk-spacing(1);
text-decoration: none;

&:hover {
text-decoration: underline;
text-decoration-thickness: 3px;
}

&:focus {
@include govuk-focused-text;
}
}

Expand Down Expand Up @@ -62,7 +65,7 @@ $icon-size: 40px;
}

.app-site-search__input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36' width='40' height='40'%3E%3Cpath d='M25.7 24.8L21.9 21c.7-1 1.1-2.2 1.1-3.5 0-3.6-2.9-6.5-6.5-6.5S10 13.9 10 17.5s2.9 6.5 6.5 6.5c1.6 0 3-.6 4.1-1.5l3.7 3.7 1.4-1.4zM12 17.5c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5-2 4.5-4.5 4.5-4.5-2-4.5-4.5z' fill='#{encode-hex(govuk-colour("dark-grey"))}'%3E%3C/path%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36' width='40' height='40'><path d='M25.7 24.8L21.9 21c.7-1 1.1-2.2 1.1-3.5 0-3.6-2.9-6.5-6.5-6.5S10 13.9 10 17.5s2.9 6.5 6.5 6.5c1.6 0 3-.6 4.1-1.5l3.7 3.7 1.4-1.4zM12 17.5c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5-2 4.5-4.5 4.5-4.5-2-4.5-4.5z' fill='%23505a5f'></path></svg>");
background-position: center left -2px;
background-repeat: no-repeat;
background-size: $icon-size $icon-size;
Expand Down Expand Up @@ -153,8 +156,8 @@ $icon-size: 40px;

.app-site-search__option--focused,
.app-site-search__option:hover {
background-color: govuk-colour("blue");
border-color: govuk-colour("blue");
background-color: $govuk-link-colour;
border-color: $govuk-link-colour;
color: govuk-colour("white");
// Add a transparent outline for when users change their colours.
outline: 3px solid transparent;
Expand All @@ -177,10 +180,6 @@ $icon-size: 40px;
@include govuk-font($size: 19);
}

.app-site-search__link {
display: none;
}

.app-site-search--section {
@include govuk-font($size: 16);
color: $govuk-secondary-text-colour;
Expand All @@ -204,20 +203,3 @@ $icon-size: 40px;
margin-right: govuk-spacing(1);
margin-left: govuk-spacing(1);
}

// No JavaScript fallback styles
.no-js .app-site-search__link {
display: none;

@include govuk-media-query($from: 900px) {
color: govuk-colour("white");
display: inline-block;
margin-top: 10px;
}
}

.no-js .app-site-search__link:focus {
@include govuk-media-query($from: 900px) {
color: govuk-colour("black");
}
}
228 changes: 105 additions & 123 deletions components/site-search/site-search.js
Original file line number Diff line number Diff line change
@@ -1,151 +1,133 @@
/* global XMLHttpRequest */
import accessibleAutocomplete from 'accessible-autocomplete/dist/accessible-autocomplete.min.js'

// CONSTANTS
const TIMEOUT = 10 // Time to wait before giving up fetching the search index
const STATE_DONE = 4 // XHR client readyState DONE

let searchIndex = null
let statusMessage = null
let searchQuery = ''
let searchCallback = function () {}
let searchResults = []

/**
* Get module
* @param {string} $module - Module name
*/
export function Search ($module) {
this.$module = $module
}
export class SiteSearchElement extends HTMLElement {
constructor () {
super()

this.statusMessage = null
this.searchInputId = 'app-site-search__input'
this.searchIndex = null
this.searchIndexUrl = this.getAttribute('index')
this.searchLabel = this.getAttribute('label')
this.searchResults = []
this.searchTimeout = 10
this.sitemapLink = this.querySelector('.app-site-search__link')
}

async fetchSearchIndex (indexUrl) {
this.statusMessage = 'Loading search index'

try {
const response = await fetch(indexUrl, {
signal: AbortSignal.timeout(this.searchTimeout * 1000)
})

Search.prototype.fetchSearchIndex = function (indexUrl, callback) {
const request = new XMLHttpRequest()
request.open('GET', indexUrl, true)
request.timeout = TIMEOUT * 1000
statusMessage = 'Loading search index'
request.onreadystatechange = function () {
if (request.readyState === STATE_DONE) {
if (request.status === 200) {
const response = request.responseText
const json = JSON.parse(response)
statusMessage = 'No results found'
searchIndex = json
callback(json)
} else {
statusMessage = 'Failed to load the search index'
if (!response.ok) {
throw Error('Search index not found')
}

const json = await response.json()
this.statusMessage = 'No results found'
this.searchIndex = json
} catch (error) {
this.statusMessage = 'Failed to load search index'
console.error(this.statusMessage, error.message)
}
}
request.send()
}

Search.prototype.findResults = function (searchQuery, searchIndex) {
return searchIndex.filter(item => {
const regex = new RegExp(searchQuery, 'gi')
return item.title.match(regex) || item.templateContent.match(regex)
})
}

Search.prototype.renderResults = function () {
if (!searchIndex) {
return searchCallback(searchResults)
findResults (searchQuery, searchIndex) {
return searchIndex.filter(item => {
const regex = new RegExp(searchQuery, 'gi')
return item.title.match(regex) || item.templateContent.match(regex)
})
}

const resultsArray = this.findResults(searchQuery, searchIndex).reverse()
renderResults (query, populateResults) {
if (!this.searchIndex) {
return populateResults(this.searchResults)
}

searchResults = resultsArray.map(function (result) {
return result
})
this.searchResults = this.findResults(query, this.searchIndex).reverse()

searchCallback(searchResults)
}
populateResults(this.searchResults)
}

Search.prototype.handleSearchQuery = function (query, callback) {
searchQuery = query
searchCallback = callback
handleOnConfirm (result) {
const path = result.url
if (!path) {
return
}

this.renderResults()
}
window.location.href = path
}

Search.prototype.handleOnConfirm = function (result) {
const path = result.url
if (!path) {
return
handleNoResults () {
return this.statusMessage
}
window.location.href = path
}

Search.prototype.inputValueTemplate = function (result) {
if (result) {
return result.title
inputValueTemplate (result) {
if (result) {
return result.title
}
}
}

Search.prototype.resultTemplate = function (result) {
if (result) {
const element = document.createElement('span')
const resultTitle = result.title
element.textContent = resultTitle
searchLabelTemplate () {
const element = document.createElement('label')
element.classList.add('govuk-visually-hidden')
element.htmlFor = this.searchInputId
element.textContent = this.searchLabel

return element
}

resultTemplate (result) {
if (result) {
const element = document.createElement('span')
element.textContent = result.title

if (result.hasFrontmatterDate || result.section) {
const section = document.createElement('span')
section.className = 'app-site-search--section'

if (result.hasFrontmatterDate || result.section) {
const section = document.createElement('span')
section.className = 'app-site-search--section'
section.innerHTML = (result.hasFrontmatterDate && result.section)
? `${result.section}<br>${result.date}`
: result.section || result.date

if (result.hasFrontmatterDate && result.section) {
section.innerHTML = `${result.section}<br>${result.date}`
} else {
section.innerHTML = result.section || result.date
element.appendChild(section)
}

element.appendChild(section)
return element.innerHTML
}

return element.innerHTML
}
}

Search.prototype.init = function () {
const $module = this.$module
if (!$module) {
return
}
async connectedCallback() {
await this.fetchSearchIndex(this.searchIndexUrl)

// The Accessible Autocomplete only works in IE9+ so we can use newer
// JavaScript features here but need to check for browsers that do not have
// these features and force the fallback by returning early.
// http://responsivenews.co.uk/post/18948466399/cutting-the-mustard
const featuresNeeded = (
'querySelector' in document &&
'addEventListener' in window &&
!!(Array.prototype && Array.prototype.forEach)
)

if (!featuresNeeded) {
return
}
// Remove fallback link to sitemap
if (this.sitemapLink) {
this.sitemapLink.remove()
}

accessibleAutocomplete({
element: $module,
id: 'app-site-search__input',
cssNamespace: 'app-site-search',
displayMenu: 'overlay',
placeholder: $module.querySelector('[for=app-site-search__input]').innerText,
confirmOnBlur: false,
autoselect: true,
source: this.handleSearchQuery.bind(this),
onConfirm: this.handleOnConfirm,
templates: {
inputValue: this.inputValueTemplate,
suggestion: this.resultTemplate
},
tNoResults: function () { return statusMessage }
})

const searchIndexUrl = $module.getAttribute('data-search-index')
this.fetchSearchIndex(searchIndexUrl, function () {
this.renderResults()
}.bind(this))
// Add label for search input
const label = this.searchLabelTemplate()
this.append(label)

accessibleAutocomplete({
element: this,
id: this.searchInputId,
cssNamespace: 'app-site-search',
displayMenu: 'overlay',
minLength: 2,
placeholder: this.searchLabel,
confirmOnBlur: false,
autoselect: true,
source: this.renderResults.bind(this),
onConfirm: this.handleOnConfirm,
templates: {
inputValue: this.inputValueTemplate,
suggestion: this.resultTemplate
},
tNoResults: this.handleNoResults.bind(this)
})
}
}

export default Search
Loading

0 comments on commit df1efbf

Please sign in to comment.