Skip to content

Commit

Permalink
Merge pull request #4218 from alphagov/final-a11y-autocomp
Browse files Browse the repository at this point in the history
Add experimental `search_with_autocomplete` component
  • Loading branch information
csutter authored Sep 20, 2024
2 parents ced7508 + 92272b2 commit 18c3069
Show file tree
Hide file tree
Showing 12 changed files with 709 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* Add files for secondary navigation: ([PR #4229](https://github.com/alphagov/govuk_publishing_components/pull/4229))
* New class to collapse columns for print ([PR #4224](https://github.com/alphagov/govuk_publishing_components/pull/4224))
* Fix homepage super navigation buttons when text scale is increased ([PR #4232](https://github.com/alphagov/govuk_publishing_components/pull/4232))
* Add experimental `search_with_autocomplete` component ([PR #4218](https://github.com/alphagov/govuk_publishing_components/pull/4218))

## 43.1.1

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/* global accessibleAutocomplete, fetch */
//= require accessible-autocomplete/dist/accessible-autocomplete.min.js

window.GOVUK = window.GOVUK || {}
window.GOVUK.Modules = window.GOVUK.Modules || {};

(function (Modules) {
class GemSearchWithAutocomplete {
constructor ($module) {
this.$module = $module

this.$originalInput = this.$module.querySelector('input')
this.$inputWrapper = this.$module.querySelector('.js-search-input-wrapper')
this.$form = this.$module.closest('form')

this.sourceUrl = this.$module.getAttribute('data-source-url')
this.sourceKey = this.$module.getAttribute('data-source-key')
}

init () {
const configOptions = {
element: this.$inputWrapper,
id: this.$originalInput.id,
name: this.$originalInput.name,
inputClasses: this.$originalInput.classList,
defaultValue: this.$originalInput.value,
cssNamespace: 'gem-c-search-with-autocomplete',
confirmOnBlur: false,
showNoOptionsFound: false,
source: this.getResults.bind(this),
onConfirm: this.submitContainingForm.bind(this),
templates: {
suggestion: this.constructSuggestionHTMLString.bind(this)
},
tStatusNoResults: () => 'No search suggestions found',
tStatusQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for search suggestions`,
tStatusResults: (length, contentSelectedOption) => {
const words = {
result: (length === 1) ? 'search suggestion' : 'search suggestions',
is: (length === 1) ? 'is' : 'are'
}

return `${length} ${words.result} ${words.is} available. ${contentSelectedOption}`
},
tAssistiveHint: () => 'When search suggestions are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.'
}
accessibleAutocomplete(configOptions)

// The accessible-autocomplete component is meant to generate a new input element rather than enhancing an existing one, so we need to do some cleanup here.
this.$autocompleteInput = this.$inputWrapper.querySelector(
'.gem-c-search-with-autocomplete__input'
)
// Ensure the new input element generated by accessible-autocomplete has the correct type
this.$autocompleteInput.setAttribute('type', 'search')
// Remove the original input from the DOM
this.$originalInput.parentNode.removeChild(this.$originalInput)
}

// Callback used by accessible-autocomplete to generate the HTML for each suggestion based on
// the values returned from the source
constructSuggestionHTMLString (result) {
const sanitizedResult = this.sanitizeResult(result)
const inputValue = this.$inputWrapper.querySelector('input').value.toLowerCase()

const index = sanitizedResult.toLowerCase().indexOf(inputValue)

let html = sanitizedResult
if (index !== -1) {
const before = sanitizedResult.slice(0, index)
const match = sanitizedResult.slice(index, index + inputValue.length)
const after = sanitizedResult.slice(index + inputValue.length)

html = `${before}<mark class="gem-c-search-with-autocomplete__suggestion-highlight">${match}</mark>${after}`
}

return `
<div class="gem-c-search-with-autocomplete__option-wrapper">
<span class="gem-c-search-with-autocomplete__suggestion-icon"></span>
<span class="gem-c-search-with-autocomplete__suggestion-text">${html}</span>
</div>
`
}

// Callback used by accessible-autocomplete to fetch results from the source
getResults (query, populateResults) {
const url = new URL(this.sourceUrl)
url.searchParams.set('q', query)
fetch(url, { headers: { Accept: 'application/json' } })
.then(response => response.json())
.then((data) => { populateResults(data[this.sourceKey]) })
.catch(() => { populateResults([]) })
}

// Callback used by accessible-autocomplete to submit the containing form when a suggestion is
// confirmed by the user (e.g. by pressing Enter or clicking on it)
submitContainingForm (value) {
if (this.$form) {
// The accessible-autocomplete component calls this callback _before_ it updates its
// internal state, so the value of the input field is not yet updated when this callback is
// called. We need to force the value to be updated before submitting the form, but the rest
// of the state can catch up later.
this.$autocompleteInput.value = value

if (this.$form.requestSubmit) {
this.$form.requestSubmit()
} else {
// Fallback for certain Grade C browsers that don't support `requestSubmit`
this.$form.submit()
}
}
}

// Sanitises a result coming back from the source to prevent XSS issues if the result happens to
// contain HTML.
sanitizeResult (value) {
const scratch = document.createElement('div')
scratch.textContent = value
return scratch.innerHTML
}
}

Modules.GemSearchWithAutocomplete = GemSearchWithAutocomplete
})(window.GOVUK.Modules)
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
@import "govuk_publishing_components/individual_component_support";

// These styles are adapted from the original Accessible Autocomplete component stylesheet, mostly
// to remove superfluous styles that are already provided by the GOV.UK Design System, and to adapt
// the styling to match the new GOV.UK search box designs (e.g. to remove the zebra striping on
// rows, adjust whitespace, and manage the tweaked markup we use in the suggestion template).
//
// Note that most selectors targetted within this file are those constructed by the Accessible
// Autocomplete component, so they may not 100% match our own component conventions.
//
// see https://github.com/alphagov/accessible-autocomplete/blob/main/src/autocomplete.css

// Helps to make the autocomplete menu as wide as the entire search box _including_ the submit
// button, not just the width of the input field.
@mixin enhance-autocomplete-menu-width($button-size) {
margin-right: -$button-size;
}

$input-size: 40px;
$large-input-size: 50px;

.gem-c-search-with-autocomplete__wrapper {
position: relative;
}

.gem-c-search-with-autocomplete__menu {
margin: 0;
padding: 0;
overflow-x: hidden;
background-color: govuk-colour("white");
border: 1px solid $govuk-border-colour;
border-top: 0;

@include enhance-autocomplete-menu-width($input-size);
}

.gem-c-search-with-autocomplete__menu--visible {
display: block;
}

.gem-c-search-with-autocomplete__menu--hidden {
display: none;
}

.gem-c-search-with-autocomplete__menu--inline {
position: relative;
}

.gem-c-search-with-autocomplete__option {
display: block;
cursor: pointer;

@include govuk-font(19);

// Ensure only the option itself receives pointer events
& > * {
pointer-events: none;
}

// Accessible Autocomplete's iOS screenreader inset has broken CSS which hasn't been fixed
// upstream, and means that its text is not just visible to screenreader users, but displayed
// for everyone. This span is added dynamically only on iOS and not given a class, so we need to
// target it in a roundabout way and make it invisible to non-screenreader users.
& > span {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}

// Common styling for _all_ focus states, including keyboard focus, mouse hover, and keyboard focus
// but mouse on another option.
.gem-c-search-with-autocomplete__option--focused,
.gem-c-search-with-autocomplete__option:hover,
.gem-c-search-with-autocomplete__option:focus-visible {
background-color: govuk-colour("light-grey");
outline: none;

@include govuk-link-decoration;
@include govuk-link-hover-decoration;

.gem-c-search-with-autocomplete__suggestion-icon {
background-color: $govuk-text-colour;
}
}

// Styling specifically _only_ for keyboard focus
.gem-c-search-with-autocomplete__option:focus-visible {
.gem-c-search-with-autocomplete__suggestion-text {
background-color: $govuk-focus-colour;
}
}

.gem-c-search-with-autocomplete__option-wrapper {
display: flex;
align-items: center;
margin: 0 govuk-spacing(3);
padding: govuk-spacing(1) 0;
border-bottom: 1px solid govuk-colour("mid-grey");
}

.gem-c-search-with-autocomplete__option:last-child .gem-c-search-with-autocomplete__option-wrapper {
border-bottom: 0;
}

.gem-c-search-with-autocomplete__suggestion-icon {
width: calc($input-size / 2);
height: $input-size;
margin-right: govuk-spacing(2);
flex: none;
mask-image: url("govuk_publishing_components/icon-autocomplete-search-suggestion.svg");
-webkit-mask-image: url("govuk_publishing_components/icon-autocomplete-search-suggestion.svg");
background-color: $govuk-secondary-text-colour;
}

.gem-c-search-with-autocomplete__suggestion-text {
font-weight: bold;
}

.gem-c-search-with-autocomplete__suggestion-highlight {
font-weight: normal;
background: none;
}

// Tweak the look and feel for the autocomplete in large mode
.gem-c-search-with-autocomplete.gem-c-search-with-autocomplete--large {
.gem-c-search-with-autocomplete__menu {
@include enhance-autocomplete-menu-width($large-input-size);
}

.gem-c-search-with-autocomplete__option {
min-height: $large-input-size;
}
}

// Fix top border styling on "borderless" search input when rendered on a GOV.UK blue background
.gem-c-search-with-autocomplete.gem-c-search-with-autocomplete--on-govuk-blue {
.gem-c-search-with-autocomplete__menu {
border-top: 1px solid $govuk-border-colour;
}
}

// High contrast mode adjustments
@media (forced-colors: active) {
.gem-c-search-with-autocomplete__menu {
border-color: FieldText;
}

.gem-c-search-with-autocomplete__option {
forced-color-adjust: none; // opt out of all default forced-colors adjustments
background-color: Field;
color: FieldText;
}

.gem-c-search-with-autocomplete__option--focused,
.gem-c-search-with-autocomplete__option:hover,
.gem-c-search-with-autocomplete__option:focus-visible {
background-color: Highlight;
color: HighlightText;
border-color: FieldText;

.gem-c-search-with-autocomplete__suggestion-text {
background: none;
}

.gem-c-search-with-autocomplete__suggestion-highlight {
color: HighlightText;
}

.gem-c-search-with-autocomplete__suggestion-icon {
background-color: HighlightText;
}
}

// Allow mouse hover styling to take precedence over keyboard focus styling
.gem-c-search-with-autocomplete__option:focus-visible:not(:hover) {
background-color: SelectedItem;
color: SelectedItemText;

.gem-c-search-with-autocomplete__suggestion-highlight {
color: SelectedItemText;
}

.gem-c-search-with-autocomplete__suggestion-icon {
background-color: SelectedItemText;
}
}

.gem-c-search-with-autocomplete__suggestion-highlight {
color: FieldText;
}

.gem-c-search-with-autocomplete__suggestion-icon {
background-color: FieldText;
}
}
30 changes: 16 additions & 14 deletions app/views/govuk_publishing_components/components/_search.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,22 @@
<%= tag_label %>
<% end %>
<div class="gem-c-search__item-wrapper">
<%= 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,
) %>
<div class="js-search-input-wrapper">
<%= 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,
) %>
</div>
<div class="gem-c-search__item gem-c-search__submit-wrapper">
<%= tag.button class: "gem-c-search__submit", type: "submit", data: data_attributes, enterkeyhint: "search" do %>
<%= button_text %>
Expand Down
Loading

0 comments on commit 18c3069

Please sign in to comment.