diff --git a/app/assets/javascripts/components/accessible-autocomplete.js b/app/assets/javascripts/components/accessible-autocomplete.js new file mode 100644 index 00000000..2358beae --- /dev/null +++ b/app/assets/javascripts/components/accessible-autocomplete.js @@ -0,0 +1,78 @@ +/* eslint-env jquery */ +/* global accessibleAutocomplete */ + +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {}; + +(function (Modules) { + 'use strict' + + Modules.AccessibleAutocomplete = function () { + var $selectElem + + this.start = function ($element) { + $selectElem = $element.find('select') + + var configOptions = { + selectElement: document.getElementById($selectElem.attr('id')), + showAllValues: true, + confirmOnBlur: true, + preserveNullOptions: true, // https://github.com/alphagov/accessible-autocomplete#null-options + defaultValue: '' + } + + configOptions.onConfirm = this.onConfirm + + new accessibleAutocomplete.enhanceSelectElement(configOptions) // eslint-disable-line no-new, new-cap + // attach the onConfirm function to data attr, to call it in finder-frontend when clearing facet tags + $selectElem.data('onconfirm', this.onConfirm) + } + + this.onConfirm = function (label, value, removeDropDown) { + function escapeHTML (str) { + return new window.Option(str).innerHTML + } + + if ($selectElem.data('track-category') !== undefined && $selectElem.data('track-action') !== undefined) { + track($selectElem.data('track-category'), $selectElem.data('track-action'), label, $selectElem.data('track-options')) + } + // This is to compensate for the fact that the accessible-autocomplete library will not + // update the hidden select if the onConfirm function is supplied + // https://github.com/alphagov/accessible-autocomplete/issues/322 + if (typeof label !== 'undefined') { + if (typeof value === 'undefined') { + value = $selectElem.children('option').filter(function () { return $(this).html() === escapeHTML(label) }).val() + } + + if (typeof value !== 'undefined') { + var $option = $selectElem.find('option[value=\'' + value + '\']') + // if removeDropDown we are clearing the selection from outside the component + var selectState = typeof removeDropDown === 'undefined' + $option.prop('selected', selectState) + $selectElem.change() + } + + // used to clear the autocomplete when clicking on a facet tag in finder-frontend + // very brittle but menu visibility is determined by autocomplete after this function is called + // setting autocomplete val to '' causes menu to appear, we don't want that, this solves it + // ideally will rewrite autocomplete to have better hooks in future + if (removeDropDown) { + $selectElem.closest('.app-c-accessible-autocomplete').addClass('app-c-accessible-autocomplete--hide-menu') + setTimeout(function () { + $('.autocomplete__menu').remove() // this element is recreated every time the user starts typing + $selectElem.closest('.app-c-accessible-autocomplete').removeClass('app-c-accessible-autocomplete--hide-menu') + }, 100) + } + } + } + + function track (category, action, label, options) { + if (window.GOVUK.analytics && window.GOVUK.analytics.trackEvent) { + options = options || {} + options.label = label + + window.GOVUK.analytics.trackEvent(category, action, options) + } + } + } +})(window.GOVUK.Modules) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 48c9cfd8..b5931830 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -15,6 +15,7 @@ $govuk-global-styles: true; @include _govuk-font-face-nta; // local components +@import "components/accessible-autocomplete"; @import "components/chart"; @import "components/ga_data_notice"; @import "components/glance-metric"; diff --git a/app/assets/stylesheets/components/_accessible-autocomplete.scss b/app/assets/stylesheets/components/_accessible-autocomplete.scss new file mode 100644 index 00000000..76852892 --- /dev/null +++ b/app/assets/stylesheets/components/_accessible-autocomplete.scss @@ -0,0 +1,33 @@ +.app-c-accessible-autocomplete { + .autocomplete__input { + z-index: 1; + } + + .autocomplete__dropdown-arrow-down { + z-index: 0; + } + + .autocomplete__option { + @include govuk-font(19); + } + + .js-enabled { + display: none; + } + + .autocomplete__list .autocomplete__option { + padding: 5px 6px; + + &:before { + position: relative; + top: 3px; + padding-top: 2px; + } + } +} + +.app-c-accessible-autocomplete--hide-menu { + .autocomplete__menu { + display: none; + } +} diff --git a/app/views/components/_accessible_autocomplete.html.erb b/app/views/components/_accessible_autocomplete.html.erb new file mode 100644 index 00000000..6cd412dd --- /dev/null +++ b/app/views/components/_accessible_autocomplete.html.erb @@ -0,0 +1,26 @@ +<% + id ||= "autocomplete-#{SecureRandom.hex(4)}" + label ||= nil + data_attributes ||= nil + options ||= [] + selected_option ||= nil + + classes = %w(app-c-accessible-autocomplete govuk-form-group) +%> +<% if label && options.any? %> + <%= tag.div class: classes, data: { module: "accessible-autocomplete" } do %> + <%= + render "govuk_publishing_components/components/label", { + html_for: id + }.merge(label.symbolize_keys) + %> + <%= + select_tag( + id, + options_for_select(options, selected_option), + class: "govuk-select", + data: data_attributes + ) + %> + <% end %> +<% end %> diff --git a/app/views/components/docs/accessible_autocomplete.yml b/app/views/components/docs/accessible_autocomplete.yml new file mode 100644 index 00000000..e9e7b0aa --- /dev/null +++ b/app/views/components/docs/accessible_autocomplete.yml @@ -0,0 +1,41 @@ +name: Accessible autocomplete +description: An autocomplete component, built to be accessible. +body: | + This component uses the [Accessible Autocomplete](https://github.com/alphagov/accessible-autocomplete) code to create an accessible autocomplete element. The autocomplete is created with the `showAllValues` + option set to `true` and the `confirmOnBlur` option set to `false` (see [Autocomplete examples](https://alphagov.github.io/accessible-autocomplete/examples) here). It also depends upon the + [label component](https://github.com/component-guide/label). + If Javascript is disabled, the component appears as a select box, so the user can still select an option. +accessibility_criteria: | + [Accessibility acceptance criteria](https://github.com/alphagov/accessible-autocomplete/blob/master/accessibility-criteria.md) +examples: + default: + data: + label: + text: 'Countries' + options: [['', ''], ['France', 'fr'], ['Germany', 'de'], ['Sweden', 'se'], ['Switzerland', 'ch'], ['United Kingdom', 'gb'], ['United States', 'us'], ['The Separate Customs Territory of Taiwan, Penghu, Kinmen, and Matsu (Chinese Taipei)', 'tw']] + with_unique_identifier: + data: + id: 'unique-autocomplete' + label: + text: 'Countries' + options: [['', ''], ['France', 'fr'], ['Germany', 'de'], ['Sweden', 'se'], ['Switzerland', 'ch'], ['United Kingdom', 'gb'], ['United States', 'us']] + with_selected_option_chosen: + data: + id: 'selected-option-chosen-autocomplete' + label: + text: 'Countries' + options: [['', ''], ['France', 'fr'], ['Germany', 'de'], ['Sweden', 'se'], ['Switzerland', 'ch'], ['United Kingdom', 'gb'], ['United States', 'us']] + selected_option: ['United Kingdom', 'gb'] + with_tracking_enabled: + description: | + This example shows tracking enabled on an autocomplete. Tracking will be enabled automatically when `track_category` and `track_action` are specified in `data_attributes`. + data: + id: 'tracking-enabled-autocomplete' + label: + text: 'Countries' + options: [['', ''], ['France', 'fr'], ['Germany', 'de'], ['Sweden', 'se'], ['Switzerland', 'ch'], ['United Kingdom', 'gb'], ['United States', 'us']] + data_attributes: + track_category: 'chosen_category' + track_action: 'chosen_action' + track_option: + custom_dimension: 'your_custom_dimension' diff --git a/app/views/content/index.html.erb b/app/views/content/index.html.erb index 6462cb8d..ac09799a 100644 --- a/app/views/content/index.html.erb +++ b/app/views/content/index.html.erb @@ -41,7 +41,7 @@ value: params[:search_term], } %> - <%= render "govuk_publishing_components/components/accessible_autocomplete", + <%= render "components/accessible_autocomplete", { id: 'document_type', label: { @@ -51,7 +51,7 @@ selected_option: @filter.selected_document_type(params) } %> - <%= render "govuk_publishing_components/components/accessible_autocomplete", + <%= render "components/accessible_autocomplete", { id: 'organisation_id', label: { diff --git a/app/views/development/index.html.erb b/app/views/development/index.html.erb index bb9873d7..3dc4dd44 100644 --- a/app/views/development/index.html.erb +++ b/app/views/development/index.html.erb @@ -2,7 +2,7 @@ <%= content_for :title, "Dev page" %> -<%= render "govuk_publishing_components/components/accessible_autocomplete", { +<%= render "components/accessible_autocomplete", { label: { text: "Countries" }, diff --git a/package.json b/package.json index 33f3a81e..a0868652 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "standardx": "^7.0.0", "stylelint": "^14.16.1", "stylelint-config-gds": "^0.2.0" + }, + "dependencies": { + "accessible-autocomplete": "^2.0.4" } } diff --git a/spec/components/accessible_autocomplete_spec.rb b/spec/components/accessible_autocomplete_spec.rb new file mode 100644 index 00000000..759a6bb6 --- /dev/null +++ b/spec/components/accessible_autocomplete_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +RSpec.describe "AccessibleAutocomplete", type: :view do + it "renders select element" do + render_component( + id: "basic-autocomplete", + label: { text: "Countries" }, + options: [["United Kingdom", "gb"], ["United States", "us"]], + ) + + assert_select ".govuk-label", text: "Countries", for: "basic-autocomplete" + assert_select "select#basic-autocomplete" + assert_select "select#basic-autocomplete option[value=gb]" + assert_select "select#basic-autocomplete option[value=gb]", text: "United Kingdom" + assert_select "select#basic-autocomplete option[value=us]" + assert_select "select#basic-autocomplete option[value=us]", text: "United States" + end + + it "renders select element with selected value" do + render_component( + id: "basic-autocomplete", + label: { text: "Countries" }, + options: [["United Kingdom", "gb"], ["United States", "us"]], + selected_option: ["United States", "us"], + ) + + assert_select ".govuk-label", text: "Countries", for: "basic-autocomplete" + assert_select "select#basic-autocomplete" + assert_select "select#basic-autocomplete option[value=gb]" + assert_select "select#basic-autocomplete option[value=us][selected]" + end + + it "does not render when no data is specified" do + assert_empty render_component({}) + end + + it "does not render when no label is specified" do + assert_empty render_component( + id: "basic-autocomplete", + options: [["United Kingdom", "gb"], ["United States", "us"]], + ) + end + + it "does not render when no options are specified" do + assert_empty render_component( + id: "basic-autocomplete", + label: { text: "Countries" }, + ) + end + + def render_component(locals) + render partial: "components/accessible_autocomplete", locals: + end +end diff --git a/spec/javascripts/components/accessible-autocomplete-spec.js b/spec/javascripts/components/accessible-autocomplete-spec.js new file mode 100644 index 00000000..227e5eb1 --- /dev/null +++ b/spec/javascripts/components/accessible-autocomplete-spec.js @@ -0,0 +1,126 @@ +/* eslint-env jasmine, jquery */ +/* global GOVUK */ + +describe('An accessible autocomplete component', function () { + 'use strict' + + function loadAutocompleteComponent () { + window.setFixtures(html) + var autocomplete = new GOVUK.Modules.AccessibleAutocomplete() + autocomplete.start($('.app-c-accessible-autocomplete')) + } + + var html = + '
' + + '' + + '
' + + // the autocomplete onConfirm function fires after the tests run unless we put + // in a timeout like this - makes the tests a bit verbose unfortunately + function testAsyncWithDeferredReturnValue () { + var deferred = $.Deferred() + + setTimeout(function () { + deferred.resolve() + }, 500) + + return deferred.promise() + } + + describe('updates the hidden select when', function () { + beforeEach(function (done) { + loadAutocompleteComponent() + + // the autocomplete is complex enough that all of these + // events are necessary to simulate user input + $('.autocomplete__input').val('Moose').click().focus().trigger( + $.Event('keypress', { which: 13, key: 13, keyCode: 13 }) + ).blur() + + testAsyncWithDeferredReturnValue().done(function () { + done() + }) + }) + + it('an option is selected', function () { + expect($('select').val()).toEqual('mo') + }) + }) + + describe('updates the hidden select when', function () { + beforeEach(function (done) { + loadAutocompleteComponent() + + $('select').val('de').change() + $('.autocomplete__input').val('Deer') + + $('.autocomplete__input').val('').click().focus().trigger( + $.Event('keypress', { which: 13, key: 13, keyCode: 13 }) + ).blur() + + testAsyncWithDeferredReturnValue().done(function () { + done() + }) + }) + + it('the input is cleared', function () { + expect($('select').val()).toEqual('') + }) + }) + + describe('triggers a Google Analytics event', function () { + beforeEach(function (done) { + GOVUK.analytics = { + trackEvent: function () { + } + } + spyOn(GOVUK.analytics, 'trackEvent') + + loadAutocompleteComponent() + + $('.autocomplete__input').val('Moose').click().focus().trigger( + $.Event('keypress', { which: 13, key: 13, keyCode: 13 }) + ).blur() + + testAsyncWithDeferredReturnValue().done(function () { + done() + }) + }) + + it('when a valid option is chosen', function () { + expect(GOVUK.analytics.trackEvent).toHaveBeenCalledWith('category', 'action', Object({ label: 'Moose' })) + }) + }) + + describe('triggers a Google Analytics event', function () { + beforeEach(function (done) { + GOVUK.analytics = { + trackEvent: function () { + } + } + spyOn(GOVUK.analytics, 'trackEvent') + + loadAutocompleteComponent() + + $('.autocomplete__input').val('Deer').click().focus().trigger( + $.Event('keypress', { which: 13, key: 13, keyCode: 13 }) + ).blur() + + $('.autocomplete__input').val('').click().focus().trigger( + $.Event('keypress', { which: 13, key: 13, keyCode: 13 }) + ).blur() + + testAsyncWithDeferredReturnValue().done(function () { + done() + }) + }) + + it('when an input is cleared', function () { + expect(GOVUK.analytics.trackEvent).toHaveBeenCalledWith('category', 'action', Object({ label: '' })) + }) + }) +})