- <%= tag.input(
- aria: {
- controls: aria_controls,
- },
- enterkeyhint: "search",
- class: "gem-c-search__item gem-c-search__input js-class-toggle",
- id: id,
- name: name,
- title: t("components.search_box.input_title"),
- type: "search",
- value: value,
- autocorrect: correction_value,
- autocapitalize: correction_value,
- ) %>
+
+ <%= tag.input(
+ aria: {
+ controls: aria_controls,
+ },
+ enterkeyhint: "search",
+ class: "gem-c-search__item gem-c-search__input js-class-toggle",
+ id: id,
+ name: name,
+ title: t("components.search_box.input_title"),
+ type: "search",
+ value: value,
+ autocorrect: correction_value,
+ autocapitalize: correction_value,
+ ) %>
+
<%= tag.button class: "gem-c-search__submit", type: "submit", data: data_attributes, enterkeyhint: "search" do %>
<%= button_text %>
diff --git a/app/views/govuk_publishing_components/components/_search_with_autocomplete.html.erb b/app/views/govuk_publishing_components/components/_search_with_autocomplete.html.erb
new file mode 100644
index 0000000000..69323a8d24
--- /dev/null
+++ b/app/views/govuk_publishing_components/components/_search_with_autocomplete.html.erb
@@ -0,0 +1,27 @@
+<%
+ add_gem_component_stylesheet("search-with-autocomplete")
+
+ source_url = local_assigns[:source_url]
+ source_key = local_assigns[:source_key]
+
+ if source_url.nil? || source_key.nil?
+ raise ArgumentError, "The search_with_autocomplete component requires source_url and source_key"
+ end
+
+ search_component_options = local_assigns.except(:autocomplete, :source_url, :source_key).merge(
+ # The `search` component has an inline label by default, but this conflicts with the accessible-
+ # autocomplete component's markup and styling. Every potential use of this component is in
+ # situations where we want the label not to be inline anyway, so we override the default here.
+ inline_label: false
+ )
+
+ classes = %w[gem-c-search-with-autocomplete]
+ classes << "gem-c-search-with-autocomplete--large" if local_assigns[:size] == "large"
+ classes << "gem-c-search-with-autocomplete--on-govuk-blue" if local_assigns[:on_govuk_blue]
+%>
+<%= tag.div(
+ class: classes.join(" "),
+ data: { module: "gem-search-with-autocomplete", source_url:, source_key: }
+) do %>
+ <%= render "govuk_publishing_components/components/search", search_component_options %>
+<% end %>
diff --git a/app/views/govuk_publishing_components/components/docs/search_with_autocomplete.yml b/app/views/govuk_publishing_components/components/docs/search_with_autocomplete.yml
new file mode 100644
index 0000000000..1ea6aeec51
--- /dev/null
+++ b/app/views/govuk_publishing_components/components/docs/search_with_autocomplete.yml
@@ -0,0 +1,61 @@
+name: Search with autocomplete (experimental)
+description: |
+ A version of the search component enhanced with the ability to fetch and display search
+ suggestions from a remote source as a user types.
+body: |
+ This component uses [Accessible Autocomplete](https://github.com/alphagov/accessible-autocomplete)
+ to enhance the [`search` component's](/component-guide/search) input element.
+ If the user does not have JavaScript enabled, the search component will function as normal.
+
+ The Accessible Autocomplete component generates its own input field (rather than working on the
+ existing one). We then remove the old input field from the DOM, and enhance the component to:
+
+ * give it the correct attributes and classes from the original input field
+ * fetch suggestions from a remote URL as the user types
+ * highlight parts of suggestions where they match the user's input
+ * submit the form the component is contained in when a suggestion is selected
+
+ The component will fetch results from the provided `source_url`, which should always return a JSON
+ response with a single object at the root, which has a property `source_key` containing an array
+ of strings. The component will then display these strings as suggestions to the user.
+
+ Note that the inline label on the `search` component conflicts with the markup and styling
+ generated internally by Accessible Autocomplete. Our current designs do not foresee us using
+ autocomplete on a search box with an inline label, and this component will always force the
+ `inline_label` option on its nested `search` component to be `false`.
+
+ Note that this component has undergone a DAC audit in September 2024, but should be considered
+ experimental until it has been AB tested on GOV.UK.
+accessibility_criteria: |
+ This component should meet the accessibility acceptance criteria outlined in the [nested search
+ component](/component-guide/search#accessibility-acceptance-criteria), as well as those of the
+ external [Accessible Autocomplete
+ Component](https://github.com/alphagov/accessible-autocomplete/blob/master/accessibility-criteria.md)
+ project.
+examples:
+ default:
+ data:
+ source_url: 'https://www.gov.uk/api/search.json?suggest=autocomplete'
+ source_key: suggested_autocomplete
+ set_input_value:
+ data:
+ source_url: 'https://www.gov.uk/api/search.json?suggest=autocomplete'
+ source_key: suggested_autocomplete
+ value: "driving licence"
+ homepage:
+ description: For use on the homepage.
+ data:
+ source_url: 'https://www.gov.uk/api/search.json?suggest=autocomplete'
+ source_key: suggested_autocomplete
+ label_text: "Search"
+ on_govuk_blue: true
+ label_size: "s"
+ homepage: true
+ size: "large"
+ context:
+ dark_background: true
+ complex_custom_label:
+ data:
+ source_url: 'https://www.gov.uk/api/search.json?suggest=autocomplete'
+ source_key: suggested_autocomplete
+ label_text:
Search GOV.UK
diff --git a/package.json b/package.json
index feb120b3c8..c40d70ae13 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
}
},
"dependencies": {
+ "accessible-autocomplete": "^3.0.0",
"axe-core": "^4.10.0",
"govuk-frontend": "5.5.0",
"govuk-single-consent": "^3.0.9",
diff --git a/spec/components/search_with_autocomplete_spec.rb b/spec/components/search_with_autocomplete_spec.rb
new file mode 100644
index 0000000000..998185ec66
--- /dev/null
+++ b/spec/components/search_with_autocomplete_spec.rb
@@ -0,0 +1,52 @@
+require "rails_helper"
+
+describe "Search with autocomplete", type: :view do
+ def component_name
+ "search_with_autocomplete"
+ end
+
+ it "requires source_url parameter to be set" do
+ expect { render_component({ source_key: "foo" }) }.to raise_error(
+ /requires source_url and source_key/,
+ )
+ end
+
+ it "requires source_key parameter to be set" do
+ expect { render_component({ source_url: "foo" }) }.to raise_error(
+ /requires source_url and source_key/,
+ )
+ end
+
+ it "renders the search component within itself" do
+ render_component({
+ source_url: "http://example.org/api/autocomplete",
+ source_key: "suggestions",
+ })
+ assert_select ".gem-c-search-with-autocomplete .gem-c-search"
+ end
+
+ it "passes on options to the search component" do
+ render_component({
+ source_url: "http://example.org/api/autocomplete",
+ source_key: "suggestions",
+ on_govuk_blue: true,
+ name: "custom_field_name",
+ button_text: "Some test text",
+ })
+
+ assert_select ".gem-c-search.gem-c-search--on-govuk-blue"
+ assert_select ".gem-c-search input[name='custom_field_name']"
+ assert_select ".gem-c-search button", text: "Some test text"
+ end
+
+ it "can render in large and govuk-blue variants" do
+ render_component({
+ source_url: "http://example.org/api/autocomplete",
+ source_key: "suggestions",
+ on_govuk_blue: true,
+ size: "large",
+ })
+
+ assert_select ".gem-c-search.gem-c-search--on-govuk-blue.gem-c-search--large"
+ end
+end
diff --git a/spec/javascripts/components/search-with-autocomplete-spec.js b/spec/javascripts/components/search-with-autocomplete-spec.js
new file mode 100644
index 0000000000..0de155b9a6
--- /dev/null
+++ b/spec/javascripts/components/search-with-autocomplete-spec.js
@@ -0,0 +1,218 @@
+/* eslint-env jasmine */
+/* global GOVUK, Event, FormData */
+
+describe('Search with autocomplete component', () => {
+ let autocomplete, fixture
+
+ const loadAutocompleteComponent = (markup) => {
+ fixture = document.createElement('div')
+ document.body.appendChild(fixture)
+ fixture.innerHTML = markup
+ autocomplete = new GOVUK.Modules.GemSearchWithAutocomplete(fixture.querySelector('.gem-c-search-with-autocomplete'))
+ }
+
+ const html = `
+
+ `
+ const performInput = (input, value, onDone) => {
+ input.value = value
+ input.dispatchEvent(new Event('input'))
+
+ // "Tick the clock" and yield compute time to the autocomplete component so it can do its work
+ // and potentially update the DOM. We could use a mutation observer for this, but that would
+ // only work if there are any results (as there would be no updates if there are no results).
+ setTimeout(onDone, 1)
+ }
+
+ const expectMenuToBeShownWithOptions = (options) => {
+ const menu = fixture.querySelector('.gem-c-search-with-autocomplete__menu')
+ const results = [...menu.querySelectorAll('.gem-c-search-with-autocomplete__option')].map(
+ (r) => r.textContent.trim()
+ )
+ expect(menu.classList).toContain('gem-c-search-with-autocomplete__menu--visible')
+ expect(results).toEqual(options)
+ }
+
+ const expectMenuNotToBeShown = () => {
+ const menu = fixture.querySelector('.gem-c-search-with-autocomplete__menu')
+ expect(menu.classList).not.toContain('gem-c-search-with-autocomplete__menu--visible')
+ }
+
+ const stubSuccessfulFetch = (suggestions) => {
+ spyOn(window, 'fetch').and.returnValue(Promise.resolve({
+ json: () => Promise.resolve({ suggestions })
+ }))
+ }
+
+ const stubLocalErrorFetch = () => {
+ spyOn(window, 'fetch').and.returnValue(Promise.reject(new Error('Network error')))
+ }
+
+ const stubServerErrorFetch = () => {
+ spyOn(window, 'fetch').and.returnValue(Promise.resolve({
+ ok: false,
+ status: 500,
+ json: () => Promise.resolve({ error: 'Internal server error' })
+ }))
+ }
+
+ beforeEach(() => {
+ loadAutocompleteComponent(html)
+ autocomplete.init()
+ })
+
+ afterEach(() => {
+ fixture.remove()
+ })
+
+ it('recreates the input exactly', () => {
+ const input = fixture.querySelector('input')
+
+ expect(input.getAttribute('name')).toEqual('q')
+ expect(input.getAttribute('id')).toEqual('search-main-7b87262d')
+ expect(input.getAttribute('type')).toEqual('search')
+ expect(input.value).toEqual("i've been looking for freedom")
+ })
+
+ it('fetches data from the correct source', (done) => {
+ const input = fixture.querySelector('input')
+ stubSuccessfulFetch(['foo'])
+
+ performInput(input, 'test query', (results) => {
+ const expectedUrl = new URL(
+ 'https://www.example.org/api/autocomplete.json?foo=bar&q=test+query'
+ )
+ expect(window.fetch).toHaveBeenCalledWith(
+ expectedUrl, { headers: { Accept: 'application/json' } }
+ )
+
+ done()
+ })
+ })
+
+ it('handles empty results coming back from source', (done) => {
+ const input = fixture.querySelector('input')
+ stubSuccessfulFetch([])
+
+ performInput(input, 'test query', () => {
+ expectMenuNotToBeShown()
+
+ done()
+ })
+ })
+
+ it('handles a local error during fetch', (done) => {
+ const input = fixture.querySelector('input')
+ stubLocalErrorFetch()
+
+ performInput(input, 'test query', () => {
+ expectMenuNotToBeShown()
+
+ done()
+ })
+ })
+
+ it('handles a server error during fetch', (done) => {
+ const input = fixture.querySelector('input')
+ stubServerErrorFetch()
+
+ performInput(input, 'test query', () => {
+ expectMenuNotToBeShown()
+
+ done()
+ })
+ })
+
+ it('populates the autocomplete with the expected options given the source response', (done) => {
+ const input = fixture.querySelector('input')
+ stubSuccessfulFetch(['foo', 'bar', 'baz'])
+
+ performInput(input, 'test query', () => {
+ expectMenuToBeShownWithOptions(['foo', 'bar', 'baz'])
+
+ done()
+ })
+ })
+
+ it('highlights the matched part of the suggestion text', (done) => {
+ const input = fixture.querySelector('input')
+ stubSuccessfulFetch(['foo bar baz'])
+
+ performInput(input, 'bar', () => {
+ const suggestionText = fixture.querySelector('.gem-c-search-with-autocomplete__suggestion-text').innerHTML
+ expect(suggestionText).toEqual('foo
bar baz')
+
+ done()
+ })
+ })
+
+ it('sanitizes potential garbled results from the source', (done) => {
+ const input = fixture.querySelector('input')
+ stubSuccessfulFetch(['
'])
+
+ performInput(input, '1999', () => {
+ const suggestionText = fixture.querySelector('.gem-c-search-with-autocomplete__suggestion-text').innerHTML
+ expect(suggestionText).toEqual('<blink>&</blink>')
+
+ done()
+ })
+ })
+
+ it('submits the containing form when a suggestion is confirmed', (done) => {
+ const form = fixture.querySelector('form')
+ const input = fixture.querySelector('input')
+ const submitSpy = spyOn(form, 'requestSubmit')
+
+ stubSuccessfulFetch(['foo'])
+ performInput(input, 'test query', () => {
+ const firstOption = fixture.querySelector('.gem-c-search-with-autocomplete__option')
+ firstOption.click()
+
+ expect(submitSpy).toHaveBeenCalled()
+ done()
+ })
+ })
+
+ it('ensures that the form is submitted with the right value in the onConfirm callback', () => {
+ const form = fixture.querySelector('form')
+ const submitSpy = spyOn(form, 'requestSubmit')
+
+ autocomplete.submitContainingForm('updated value')
+
+ const formData = new FormData(form)
+ expect(formData.get('q')).toEqual('updated value')
+ expect(submitSpy).toHaveBeenCalled()
+ })
+})
diff --git a/spec/lib/govuk_publishing_components/app_helpers/asset_helper_spec.rb b/spec/lib/govuk_publishing_components/app_helpers/asset_helper_spec.rb
index bd091548ee..3b5fd776bb 100644
--- a/spec/lib/govuk_publishing_components/app_helpers/asset_helper_spec.rb
+++ b/spec/lib/govuk_publishing_components/app_helpers/asset_helper_spec.rb
@@ -19,7 +19,7 @@ def request
end
it "detect the total number of stylesheet paths" do
- expect(get_component_css_paths.count).to eql(78)
+ expect(get_component_css_paths.count).to eql(79)
end
it "initialize empty asset helper" do
diff --git a/yarn.lock b/yarn.lock
index f93b00d572..550a09f05f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -276,6 +276,11 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
+accessible-autocomplete@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/accessible-autocomplete/-/accessible-autocomplete-3.0.0.tgz#f66ed03fb22d78f721326d187ee491dddbadec75"
+ integrity sha512-Kpm6EX+jjD0AurWfzSP4EVLEKsLUWCazZwidjum+8FCRtSINeaPzVa3ElKVGWvSqVZN9zjeSBF8cirhYEZjW1A==
+
acorn-jsx@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b"