Skip to content

Commit

Permalink
Améliore accessibilité des champs avec autocomplétion
Browse files Browse the repository at this point in the history
  • Loading branch information
florimondmanca committed May 23, 2024
1 parent 96cc4b0 commit d581676
Show file tree
Hide file tree
Showing 17 changed files with 229 additions and 50 deletions.
124 changes: 108 additions & 16 deletions assets/controllers/_stimulus_autocomplete.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Controller } from "@hotwired/stimulus"
import { Idiomorph } from 'idiomorph/dist/idiomorph.esm';

const optionSelector = "[role='option']:not([aria-disabled])"
const activeSelector = "[aria-selected='true']"

export default class Autocomplete extends Controller {
static targets = ["input", "hidden", "results"]
static targets = ["input", "hidden", "results", "status"]
static classes = ["selected"]
static values = {
ready: Boolean,
submitOnEnter: Boolean,
url: String,
minLength: Number,
loadingStatus: { type: String, default: '' },
emptyStatus: { type: String, default: '' },
delay: { type: Number, default: 300 },
queryParam: { type: String, default: "q" },
fetchEmpty: { type: Boolean, default: false },
Expand All @@ -20,6 +23,19 @@ export default class Autocomplete extends Controller {
connect() {
this.close()

// Accessibility attributes
// See: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/
// Announce that this is a combobox with list of results
this.inputTarget.setAttribute("role", "combobox")
this.inputTarget.setAttribute("aria-autocomplete", "list")
// Link the input to the list of results
const resultsId = this.resultsTarget.id
if (!resultsId) {
throw new Error('[a11y]: results element must have an id="..."')
}
this.inputTarget.setAttribute("aria-controls", resultsId)
this.inputTarget.setAttribute('aria-describedby', resultsId) // Announce list of items and status when they change

if(!this.inputTarget.hasAttribute("autocomplete")) this.inputTarget.setAttribute("autocomplete", "off")
this.inputTarget.setAttribute("spellcheck", "false")

Expand All @@ -37,6 +53,10 @@ export default class Autocomplete extends Controller {
this.inputTarget.focus()
}

if (!this.inputTarget.value && this.hasStatusTarget && this.emptyStatusValue) {
this.statusTarget.textContent = this.emptyStatusValue
}

this.readyValue = true
}

Expand All @@ -62,10 +82,25 @@ export default class Autocomplete extends Controller {
return sibling || def
}

selectFirst() {
const first = this.options[0]
if (first) {
this.select(first)
}
}

selectLast() {
const options = this.options
const last = options[options.length - 1]
if (last) {
this.select(last)
}
}

select(target) {
const previouslySelected = this.selectedOption
if (previouslySelected) {
previouslySelected.removeAttribute("aria-selected")
previouslySelected.setAttribute("aria-selected", "false")
previouslySelected.classList.remove(...this.selectedClassesOrDefault)
}

Expand All @@ -81,22 +116,35 @@ export default class Autocomplete extends Controller {
}

onEscapeKeydown = (event) => {
if (!this.resultsShown) return
if (!this.resultsShown) {
this.clear()
return
}

this.hideAndRemoveOptions()
this.close()
event.stopPropagation()
event.preventDefault()
}

onArrowDownKeydown = (event) => {
const item = this.sibling(true)
if (item) this.select(item)
if (!this.resultsShown) {
this.open()
this.selectFirst()
} else {
const item = this.sibling(true)
if (item) this.select(item)
}
event.preventDefault()
}

onArrowUpKeydown = (event) => {
const item = this.sibling(false)
if (item) this.select(item)
if (!this.resultsShown) {
this.open()
this.selectLast()
} else {
const item = this.sibling(false)
if (item) this.select(item)
}
event.preventDefault()
}

Expand Down Expand Up @@ -142,7 +190,8 @@ export default class Autocomplete extends Controller {
}

this.inputTarget.focus()
this.hideAndRemoveOptions()
this.close()
this.resetOptions()

this.element.dispatchEvent(
new CustomEvent("autocomplete.change", {
Expand Down Expand Up @@ -177,7 +226,7 @@ export default class Autocomplete extends Controller {
if ((query && query.length >= this.minLengthValue) || (!query && this.fetchEmptyValue)) {
this.fetchResults(query)
} else {
this.hideAndRemoveOptions()
this.resetOptions()
}
}

Expand All @@ -187,9 +236,25 @@ export default class Autocomplete extends Controller {
optionsWithoutId.forEach(el => el.id = `${prefix}-option-${Autocomplete.uniqOptionId++}`)
}

hideAndRemoveOptions() {
this.close()
this.resultsTarget.innerHTML = null
resetOptions() {
if (this.hasStatusTarget && this.emptyStatusValue) {
this.setStatus(this.emptyStatusValue)
}

this.morphResults('')
}

setStatus(text) {
this.statusTarget.textContent = text
}

showLoadingStatus() {
if (!this.hasStatusTarget || !this.loadingStatusValue) {
return
}
this.resultsShown = true
this.inputTarget.setAttribute("aria-expanded", "true")
this.setStatus(this.loadingStatusValue)
}

fetchResults = async (query) => {
Expand All @@ -198,6 +263,7 @@ export default class Autocomplete extends Controller {
const url = this.buildURL(query)
try {
this.element.dispatchEvent(new CustomEvent("loadstart"))
this.showLoadingStatus()
const html = await this.doFetch(url)
this.replaceResults(html)
this.element.dispatchEvent(new CustomEvent("load"))
Expand Down Expand Up @@ -229,8 +295,34 @@ export default class Autocomplete extends Controller {
return html
}

morphResults(html) {
// Be sure to morph while keeping the <li role="status"> reference the same,
// otherwise screen readers may not announce the new status.
if (this.hasStatusTarget) {
html += this.statusTarget.outerHTML;
}

Idiomorph.morph(this.resultsTarget, html, {
morphStyle: 'innerHTML'
});

const statusTemplate = this.resultsTarget.querySelector('template[id="status"]');

if (statusTemplate) {
if (this.hasStatusTarget) {
// Load HTML string into a throaway div
const div = document.createElement('div');
div.appendChild(statusTemplate.content.cloneNode(true));
this.setStatus(div.textContent);
}

statusTemplate.remove();
}
}

replaceResults(html) {
this.resultsTarget.innerHTML = html
this.close()
this.morphResults(html)
this.identifyOptions()
if (!!this.options) {
this.open()
Expand All @@ -243,7 +335,7 @@ export default class Autocomplete extends Controller {
if (this.resultsShown) return

this.resultsShown = true
this.element.setAttribute("aria-expanded", "true")
this.inputTarget.setAttribute("aria-expanded", "true")
this.element.dispatchEvent(
new CustomEvent("toggle", {
detail: { action: "open", inputTarget: this.inputTarget, resultsTarget: this.resultsTarget }
Expand All @@ -256,7 +348,7 @@ export default class Autocomplete extends Controller {

this.resultsShown = false
this.inputTarget.removeAttribute("aria-activedescendant")
this.element.setAttribute("aria-expanded", "false")
this.inputTarget.setAttribute("aria-expanded", "false")
this.element.dispatchEvent(
new CustomEvent("toggle", {
detail: { action: "close", inputTarget: this.inputTarget, resultsTarget: this.resultsTarget }
Expand Down
2 changes: 1 addition & 1 deletion assets/controllers/autocomplete_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default class Autocomplete extends StimulusAutocomplete {
// Action callbacks

reset() {
this.resultsTarget.innerHTML = '';
this.resetOptions();
this._fetchManager.reset();
}
}
Expand Down
15 changes: 13 additions & 2 deletions assets/styles/components/autocomplete.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,26 @@
z-index: 3;
position: absolute;

li[role="option"] {
li[role="option"], li[role="status"] {
border-bottom: 1px solid var(--background-contrast-grey-active);
padding: 10px;
cursor: pointer;
background: var(--background-default-grey);
}

li[role="option"] {
cursor: pointer;
}

li[role="option"]:hover, li[role="option"].active, li[role="option"][aria-selected="true"] {
background-color: var(--background-contrast-grey);
}

li[role="status"] {
font-style: italic;
cursor: default;
}
li[role="status"]:empty {
display: none;
}
}
}
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"dependencies": {
"@gouvfr/dsfr": "^1.7.2",
"idiomorph": "^0.3.0",
"remixicon": "^2.5.0"
},
"engines": {
Expand Down
6 changes: 5 additions & 1 deletion templates/location/named_street/_from_point.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
}|json_encode }}"
data-autocomplete-min-length-value="3"
data-autocomplete-delay-value="500"
data-autocomplete-loading-status-value="{{ 'common.autocomplete.status.loading'|trans }}"
data-autocomplete-empty-status-value="{{ 'common.autocomplete.status.min_chars'|trans({ '%minChars%': 3 }) }}"
data-autocomplete-prefetch-value="true"
data-autocomplete-fetch-empty-value="true"
>
Expand All @@ -82,7 +84,9 @@
aria-label="{{ 'regulation.location.named_street.intersection.results_label'|trans }}"
class="fr-x-autocomplete"
data-autocomplete-target="results"
></ul>
>
<li role="status" data-autocomplete-target="status"></li>
</ul>
</div>
</div>
</fieldset>
6 changes: 5 additions & 1 deletion templates/location/named_street/_to_point.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
}|json_encode }}"
data-autocomplete-min-length-value="3"
data-autocomplete-delay-value="500"
data-autocomplete-loading-status-value="{{ 'common.autocomplete.status.loading'|trans }}"
data-autocomplete-empty-status-value="{{ 'common.autocomplete.status.min_chars'|trans({ '%minChars%': 3 }) }}"
data-autocomplete-prefetch-value="true"
data-autocomplete-fetch-empty-value="true"
>
Expand All @@ -83,7 +85,9 @@
aria-label="{{ 'regulation.location.named_street.intersection.results_label'|trans }}"
class="fr-x-autocomplete"
data-autocomplete-target="results"
></ul>
>
<li role="status" data-autocomplete-target="status"></li>
</ul>
</div>
</div>
</fieldset>
4 changes: 4 additions & 0 deletions templates/regulation/fragments/_city_completions.html.twig
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{% for city in cities %}
<li role="option" data-autocomplete-value="{{ city.code }}">{{ city.label }}</li>
{% endfor %}

<template id="status">
{{ 'common.autocomplete.results_count'|trans({ '%count%': cities|length }) }}
</template>
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{% for roadName in roadNames %}
<li role="option">{{ roadName }}</li>
{% endfor %}

<template id="status">
{{ 'common.autocomplete.results_count'|trans({ '%count%': roadNames|length }) }}
</template>
Loading

0 comments on commit d581676

Please sign in to comment.