Browse Source
* Add accessible autocomplete improvements * lint * Add a check for matchespull/731/head
kosiakkatrina
2 years ago
committed by
GitHub
5 changed files with 166 additions and 18 deletions
@ -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 |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
@ -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 = `<span>${value}</span>` |
||||
return html |
||||
} else { |
||||
return '<span>No results found</span>' |
||||
} |
||||
} |
||||
|
||||
export const enhanceOption = (option) => { |
||||
return { |
||||
name: option.label, |
||||
boost: parseFloat(option.getAttribute('data-boost')) || 1 |
||||
} |
||||
} |
Loading…
Reference in new issue