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 @@ - + +{%- if params.sitemapPath -%} + Sitemap +{%- endif -%} + diff --git a/layouts/base.njk b/layouts/base.njk index d5f7ca56..0acc101b 100644 --- a/layouts/base.njk +++ b/layouts/base.njk @@ -1,7 +1,6 @@ {% extends "govuk/template.njk" %} {% set assetUrl = assetPath | absoluteUrl(options.url) %} -{% set htmlClasses = "no-js" %} {% set themeColor = options.themeColour %} {# Only show default Open Graph image if document does not provide its own #} @@ -71,6 +70,5 @@ {% block bodyEnd %} - {% block scripts %}{% endblock %} {% endblock %} diff --git a/lib/govuk.js b/lib/govuk.js index bf6ab094..be526028 100644 --- a/lib/govuk.js +++ b/lib/govuk.js @@ -1,11 +1,10 @@ -// Import GOV.UK Frontend import { initAll as GOVUKFrontend } from 'govuk-frontend' +import { SiteSearchElement } from '../components/site-search/site-search.js' -// Import plugin components -import { initAll as GOVUK11ty } from '../components/all.js' +// Initiate custom elements +customElements.define("site-search", SiteSearchElement) // Initiate scripts on page load document.addEventListener('DOMContentLoaded', () => { GOVUKFrontend() - GOVUK11ty() }) diff --git a/package.json b/package.json index a9e7b43e..87059428 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ }, "eslintConfig": { "env": { + "browser": true, "node": true }, "extends": [