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

Refactor site search to use custom element #218

Merged
merged 1 commit into from
Dec 12, 2023
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
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