diff --git a/components/all.js b/components/all.js
deleted file mode 100644
index b456f2c6..00000000
--- a/components/all.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import SiteSearch from './site-search/site-search.js'
-
-/**
- * Initiate all component modules
- */
-export const initAll = function () {
- const scope = document.documentElement
-
- const siteSearch = scope.querySelectorAll('[data-module="app-site-search"]')
- siteSearch.forEach(element => {
- new SiteSearch(element).init()
- })
-}
diff --git a/components/site-search/_site-search.scss b/components/site-search/_site-search.scss
index 100117d7..e0ba1cad 100644
--- a/components/site-search/_site-search.scss
+++ b/components/site-search/_site-search.scss
@@ -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;
}
}
@@ -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,");
background-position: center left -2px;
background-repeat: no-repeat;
background-size: $icon-size $icon-size;
@@ -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;
@@ -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;
@@ -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");
- }
-}
diff --git a/components/site-search/site-search.js b/components/site-search/site-search.js
index 42da7ee3..a80fbaa1 100644
--- a/components/site-search/site-search.js
+++ b/components/site-search/site-search.js
@@ -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}
${result.date}`
+ : result.section || result.date
- if (result.hasFrontmatterDate && result.section) {
- section.innerHTML = `${result.section}
${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
diff --git a/components/site-search/template.njk b/components/site-search/template.njk
index 21df70e6..be5b9169 100644
--- a/components/site-search/template.njk
+++ b/components/site-search/template.njk
@@ -1,4 +1,5 @@
-