From 5c25bf1c9bc3cfbbbee4eba1a94305184b5c72b1 Mon Sep 17 00:00:00 2001 From: Kat Date: Mon, 11 Jul 2022 15:21:49 +0100 Subject: [PATCH] Add accessible autocomplete improvements --- .../accessible_autocomplete_controller.js | 38 +++++-- 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, 172 insertions(+), 24 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..935594529 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 { 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 () { + 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[1]}[${matches[2]}_raw]`; + accessibleAutocomplete.enhanceSelectElement({ - defaultValue: '', - selectElement: this.element - }) + defaultValue: "", + 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..91966be9a --- /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