From c44304093fc136a5973d78d4a92b242991a15007 Mon Sep 17 00:00:00 2001
From: kosiakkatrina <54268893+kosiakkatrina@users.noreply.github.com>
Date: Tue, 12 Jul 2022 09:48:26 +0100
Subject: [PATCH] Add accessible autocomplete improvements (#728)
* Add accessible autocomplete improvements
* lint
* Add a check for matches
---
.../accessible_autocomplete_controller.js | 26 ++++-
app/frontend/modules/search.js | 106 ++++++++++++++++++
.../form/accessible_autocomplete_spec.rb | 48 +++++---
spec/fixtures/forms/2021_2022.json | 2 +-
spec/models/form/question_spec.rb | 2 +-
5 files changed, 166 insertions(+), 18 deletions(-)
create mode 100644 app/frontend/modules/search.js
diff --git a/app/frontend/controllers/accessible_autocomplete_controller.js b/app/frontend/controllers/accessible_autocomplete_controller.js
index d3c900485..0b17e8aa8 100644
--- a/app/frontend/controllers/accessible_autocomplete_controller.js
+++ b/app/frontend/controllers/accessible_autocomplete_controller.js
@@ -1,12 +1,36 @@
import { Controller } from '@hotwired/stimulus'
import accessibleAutocomplete from 'accessible-autocomplete'
import 'accessible-autocomplete/dist/accessible-autocomplete.min.css'
+import { enhanceOption, suggestion, sort } from '../modules/search'
export default class extends Controller {
connect () {
+ const selectEl = this.element
+ const selectOptions = Array.from(selectEl.options)
+ const options = selectOptions.map((o) => enhanceOption(o))
+
+ const matches = /^(\w+)\[(\w+)\]$/.exec(selectEl.name)
+ const rawFieldName = matches ? `${matches[1]}[${matches[2]}_raw]` : ''
+
accessibleAutocomplete.enhanceSelectElement({
defaultValue: '',
- selectElement: this.element
+ selectElement: selectEl,
+ minLength: 2,
+ source: (query, populateResults) => {
+ if (/\S/.test(query)) {
+ populateResults(sort(query, options))
+ }
+ },
+ autoselect: true,
+ templates: { suggestion: (value) => suggestion(value, options) },
+ name: rawFieldName,
+ onConfirm: (val) => {
+ const selectedOption = [].filter.call(
+ selectOptions,
+ (option) => (option.textContent || option.innerText) === val
+ )[0]
+ if (selectedOption) selectedOption.selected = true
+ }
})
}
}
diff --git a/app/frontend/modules/search.js b/app/frontend/modules/search.js
new file mode 100644
index 000000000..d9d233ab1
--- /dev/null
+++ b/app/frontend/modules/search.js
@@ -0,0 +1,106 @@
+const addWeightWithBoost = (option, query) => {
+ option.weight = calculateWeight(option.clean, query) * option.clean.boost
+
+ return option
+}
+
+const clean = (text) =>
+ text
+ .trim()
+ .replace(/['’]/g, '')
+ .replace(/[.,"/#!$%^&*;:{}=\-_`~()]/g, ' ')
+ .toLowerCase()
+
+const cleanseOption = (option) => {
+ option.clean = {
+ name: clean(option.name),
+ nameWithoutStopWords: removeStopWords(option.name),
+ boost: option.boost || 1
+ }
+
+ return option
+}
+
+const hasWeight = (option) => option.weight > 0
+
+const byWeightThenAlphabetically = (a, b) => {
+ if (a.weight > b.weight) return -1
+ if (a.weight < b.weight) return 1
+ if (a.name < b.name) return -1
+ if (a.name > b.name) return 1
+
+ return 0
+}
+
+const optionName = (option) => option.name
+const exactMatch = (word, query) => word === query
+
+const startsWithRegExp = (query) => new RegExp('\\b' + query, 'i')
+const startsWith = (word, query) => word.search(startsWithRegExp(query)) === 0
+
+const wordsStartsWithQuery = (word, regExps) =>
+ regExps.every((regExp) => word.search(regExp) >= 0)
+
+const calculateWeight = ({ name, nameWithoutStopWords }, query) => {
+ const queryWithoutStopWords = removeStopWords(query)
+
+ if (exactMatch(name, query)) return 100
+ if (exactMatch(nameWithoutStopWords, queryWithoutStopWords)) return 95
+
+ if (startsWith(name, query)) return 60
+ if (startsWith(nameWithoutStopWords, queryWithoutStopWords)) return 55
+ const startsWithRegExps = queryWithoutStopWords
+ .split(/\s+/)
+ .map(startsWithRegExp)
+
+ if (wordsStartsWithQuery(nameWithoutStopWords, startsWithRegExps)) return 25
+
+ return 0
+}
+
+const stopWords = ['the', 'of', 'in', 'and', 'at', '&']
+
+const removeStopWords = (text) => {
+ const isAllStopWords = text
+ .trim()
+ .split(' ')
+ .every((word) => stopWords.includes(word))
+
+ if (isAllStopWords) {
+ return text
+ }
+
+ const regex = new RegExp(
+ stopWords.map((word) => `(\\s+)?${word}(\\s+)?`).join('|'),
+ 'gi'
+ )
+ return text.replace(regex, ' ').trim()
+}
+
+export const sort = (query, options) => {
+ const cleanQuery = clean(query)
+
+ return options
+ .map(cleanseOption)
+ .map((option) => addWeightWithBoost(option, cleanQuery))
+ .filter(hasWeight)
+ .sort(byWeightThenAlphabetically)
+ .map(optionName)
+}
+
+export const suggestion = (value, options) => {
+ const option = options.find((o) => o.name === value)
+ if (option) {
+ const html = `${value}`
+ return html
+ } else {
+ return 'No results found'
+ }
+}
+
+export const enhanceOption = (option) => {
+ return {
+ name: option.label,
+ boost: parseFloat(option.getAttribute('data-boost')) || 1
+ }
+}
diff --git a/spec/features/form/accessible_autocomplete_spec.rb b/spec/features/form/accessible_autocomplete_spec.rb
index e196954da..a8537199b 100644
--- a/spec/features/form/accessible_autocomplete_spec.rb
+++ b/spec/features/form/accessible_autocomplete_spec.rb
@@ -21,23 +21,41 @@ RSpec.describe "Accessible Automcomplete" do
sign_in user
end
- it "allows type ahead filtering", js: true do
- visit("/logs/#{case_log.id}/accessible-select")
- find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter)
- expect(find("#case-log-prevloc-field").value).to eq("Thanet")
- end
+ context "when using accessible autocomplete" do
+ before do
+ visit("/logs/#{case_log.id}/accessible-select")
+ end
- it "maintains enhancement state across back navigation", js: true do
- visit("/logs/#{case_log.id}/accessible-select")
- find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter)
- click_button("Save and continue")
- click_link(text: "Back")
- expect(page).to have_selector("input", class: "autocomplete__input", count: 1)
- end
+ it "allows type ahead filtering", js: true do
+ find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter)
+ expect(find("#case-log-prevloc-field").value).to eq("Thanet")
+ end
- it "has a disabled null option" do
- visit("/logs/#{case_log.id}/accessible-select")
- expect(page).to have_select("case-log-prevloc-field", disabled_options: ["Select an option"])
+ it "ignores punctuation", js: true do
+ find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "'", "n", :down, :enter)
+ expect(find("#case-log-prevloc-field").value).to eq("Thanet")
+ end
+
+ it "ignores stop words", js: true do
+ find("#case-log-prevloc-field").click.native.send_keys("t", "h", "e", " ", "W", "e", "s", "t", "m", :down, :enter)
+ expect(find("#case-log-prevloc-field").value).to eq("Westminster")
+ end
+
+ it "does not perform an exact match", js: true do
+ find("#case-log-prevloc-field").click.native.send_keys("o", "n", "l", "y", " ", "t", "o", "w", "n", :down, :enter)
+ expect(find("#case-log-prevloc-field").value).to eq("The one and only york town")
+ end
+
+ it "maintains enhancement state across back navigation", js: true do
+ find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter)
+ click_button("Save and continue")
+ click_link(text: "Back")
+ expect(page).to have_selector("input", class: "autocomplete__input", count: 1)
+ end
+
+ it "has a disabled null option" do
+ expect(page).to have_select("case-log-prevloc-field", disabled_options: ["Select an option"])
+ end
end
it "has the correct option selected if one has been saved" do
diff --git a/spec/fixtures/forms/2021_2022.json b/spec/fixtures/forms/2021_2022.json
index f8afa669e..9a24381e9 100644
--- a/spec/fixtures/forms/2021_2022.json
+++ b/spec/fixtures/forms/2021_2022.json
@@ -326,7 +326,7 @@
"E07000178": "Oxford",
"E07000114": "Thanet",
"E09000033": "Westminster",
- "E06000014": "York"
+ "E06000014": "The one and only york town"
}
}
},
diff --git a/spec/models/form/question_spec.rb b/spec/models/form/question_spec.rb
index 78909766b..31021fe90 100644
--- a/spec/models/form/question_spec.rb
+++ b/spec/models/form/question_spec.rb
@@ -158,7 +158,7 @@ RSpec.describe Form::Question, type: :model do
end
it "can map label from value" do
- expect(question.label_from_value("E06000014")).to eq("York")
+ expect(question.label_from_value("E09000033")).to eq("Westminster")
end
context "when the saved answer is not in the value map" do