diff --git a/assets/controllers/_stimulus_autocomplete.js b/assets/controllers/_stimulus_autocomplete.js index 4ae5cbba6..465e350a5 100644 --- a/assets/controllers/_stimulus_autocomplete.js +++ b/assets/controllers/_stimulus_autocomplete.js @@ -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 }, @@ -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") @@ -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 } @@ -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) } @@ -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() } @@ -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", { @@ -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() } } @@ -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) => { @@ -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")) @@ -229,8 +295,34 @@ export default class Autocomplete extends Controller { return html } + morphResults(html) { + // Be sure to morph while keeping the
  • 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() @@ -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 } @@ -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 } diff --git a/assets/controllers/autocomplete_controller.js b/assets/controllers/autocomplete_controller.js index 04c68249a..f95e79c6b 100644 --- a/assets/controllers/autocomplete_controller.js +++ b/assets/controllers/autocomplete_controller.js @@ -75,7 +75,7 @@ export default class Autocomplete extends StimulusAutocomplete { // Action callbacks reset() { - this.resultsTarget.innerHTML = ''; + this.resetOptions(); this._fetchManager.reset(); } } diff --git a/assets/styles/components/autocomplete.scss b/assets/styles/components/autocomplete.scss index 54c195056..2f48b101f 100644 --- a/assets/styles/components/autocomplete.scss +++ b/assets/styles/components/autocomplete.scss @@ -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; + } } } diff --git a/package-lock.json b/package-lock.json index a7ea2f12f..62336d5da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@gouvfr/dsfr": "^1.7.2", + "idiomorph": "^0.3.0", "remixicon": "^2.5.0" }, "devDependencies": { @@ -4941,6 +4942,11 @@ "postcss": "^8.1.0" } }, + "node_modules/idiomorph": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/idiomorph/-/idiomorph-0.3.0.tgz", + "integrity": "sha512-UhV1Ey5xCxIwR9B+OgIjQa+1Jx99XQ1vQHUsKBU1RpQzCx1u+b+N6SOXgf5mEJDqemUI/ffccu6+71l2mJUsRA==" + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -8524,6 +8530,7 @@ } }, "vendor/symfony/stimulus-bundle/assets": { + "name": "@symfony/stimulus-bundle", "version": "1.0.0", "dev": true, "license": "MIT", @@ -8533,6 +8540,7 @@ } }, "vendor/symfony/ux-turbo/assets": { + "name": "@symfony/ux-turbo", "version": "0.1.0", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index a2589053a..18a34c5f8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@gouvfr/dsfr": "^1.7.2", + "idiomorph": "^0.3.0", "remixicon": "^2.5.0" }, "engines": { diff --git a/templates/location/named_street/_from_point.html.twig b/templates/location/named_street/_from_point.html.twig index 8d1d64dc5..18393240c 100644 --- a/templates/location/named_street/_from_point.html.twig +++ b/templates/location/named_street/_from_point.html.twig @@ -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" > @@ -82,7 +84,9 @@ aria-label="{{ 'regulation.location.named_street.intersection.results_label'|trans }}" class="fr-x-autocomplete" data-autocomplete-target="results" - > + > +
  • + diff --git a/templates/location/named_street/_to_point.html.twig b/templates/location/named_street/_to_point.html.twig index 27c0dc176..ab9fd2671 100644 --- a/templates/location/named_street/_to_point.html.twig +++ b/templates/location/named_street/_to_point.html.twig @@ -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" > @@ -83,7 +85,9 @@ aria-label="{{ 'regulation.location.named_street.intersection.results_label'|trans }}" class="fr-x-autocomplete" data-autocomplete-target="results" - > + > +
  • + diff --git a/templates/regulation/fragments/_city_completions.html.twig b/templates/regulation/fragments/_city_completions.html.twig index 7b738cf25..dff3fdb4e 100644 --- a/templates/regulation/fragments/_city_completions.html.twig +++ b/templates/regulation/fragments/_city_completions.html.twig @@ -1,3 +1,7 @@ {% for city in cities %}
  • {{ city.label }}
  • {% endfor %} + + diff --git a/templates/regulation/fragments/_intersection_completions.html.twig b/templates/regulation/fragments/_intersection_completions.html.twig index fad2ac7a7..7974b188a 100644 --- a/templates/regulation/fragments/_intersection_completions.html.twig +++ b/templates/regulation/fragments/_intersection_completions.html.twig @@ -1,3 +1,7 @@ {% for roadName in roadNames %}
  • {{ roadName }}
  • {% endfor %} + + diff --git a/templates/regulation/fragments/_measure_form.html.twig b/templates/regulation/fragments/_measure_form.html.twig index 412cdada3..078dc251f 100644 --- a/templates/regulation/fragments/_measure_form.html.twig +++ b/templates/regulation/fragments/_measure_form.html.twig @@ -199,6 +199,8 @@ data-autocomplete-extra-query-params-value="{{ {administrator: '#' ~ form.numberedRoad.administrator.vars.id}|json_encode }}" data-autocomplete-min-length-value="2" 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%': 2 }) }}" data-action="autocomplete.change->reset#reset" data-reset-key-param="roadNumber" > @@ -216,7 +218,9 @@ role="listbox" aria-label="{{ 'regulation.location.roadNumber.results_label'|trans }}" class="fr-x-autocomplete" - data-autocomplete-target="results"> + data-autocomplete-target="results" + > +
  • @@ -271,6 +275,8 @@ data-autocomplete-query-param-value="search" 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-action="autocomplete.change->reset#reset" data-reset-key-param="city" > @@ -290,7 +296,9 @@ aria-label="{{ 'regulation.location.city.results_label'|trans }}" class="fr-x-autocomplete" data-autocomplete-target="results" - > + > +
  • +
    @@ -318,7 +329,9 @@ aria-label="{{ 'regulation.location.roadName.results_label'|trans }}" class="fr-x-autocomplete" data-autocomplete-target="results" - > + > +
  • +
    diff --git a/templates/regulation/fragments/_road_name_completions.html.twig b/templates/regulation/fragments/_road_name_completions.html.twig index fad2ac7a7..7974b188a 100644 --- a/templates/regulation/fragments/_road_name_completions.html.twig +++ b/templates/regulation/fragments/_road_name_completions.html.twig @@ -1,3 +1,7 @@ {% for roadName in roadNames %}
  • {{ roadName }}
  • {% endfor %} + + diff --git a/templates/regulation/fragments/_road_numbers_completions.html.twig b/templates/regulation/fragments/_road_numbers_completions.html.twig index c50923800..a930eef92 100644 --- a/templates/regulation/fragments/_road_numbers_completions.html.twig +++ b/templates/regulation/fragments/_road_numbers_completions.html.twig @@ -1,3 +1,7 @@ {% for roadNumber in departmentalRoadNumbers %}
  • {{ roadNumber.roadNumber }}
  • {% endfor %} + + diff --git a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetAddressCompletionFragmentControllerTest.php b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetAddressCompletionFragmentControllerTest.php index 566f30824..155fb41aa 100644 --- a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetAddressCompletionFragmentControllerTest.php +++ b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetAddressCompletionFragmentControllerTest.php @@ -16,9 +16,10 @@ public function testStreetAutoComplete(): void $this->assertResponseStatusCodeSame(200); $this->assertSecurityHeaders(); - $li = $crawler->filter('li'); - $this->assertSame(1, $li->count()); - $this->assertSame('Rue Eugène Berthoud', $li->eq(0)->text()); + $this->assertSame('1 résultat trouvé', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(1, $options->count()); + $this->assertSame('Rue Eugène Berthoud', $options->eq(0)->text()); } public function testBadRequest(): void diff --git a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetCityCompletionFragmentControllerTest.php b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetCityCompletionFragmentControllerTest.php index ba194dc7e..c08c4c405 100644 --- a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetCityCompletionFragmentControllerTest.php +++ b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetCityCompletionFragmentControllerTest.php @@ -16,17 +16,31 @@ public function testCityAutoComplete(): void $this->assertResponseStatusCodeSame(200); $this->assertSecurityHeaders(); - $li = $crawler->filter('li'); - $this->assertSame(3, $li->count()); + $this->assertSame('3 résultats trouvés', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(3, $options->count()); - $this->assertSame('Blanc Mesnil (93150)', $li->eq(0)->text()); - $this->assertSame('93007', $li->eq(0)->attr('data-autocomplete-value')); + $this->assertSame('Blanc Mesnil (93150)', $options->eq(0)->text()); + $this->assertSame('93007', $options->eq(0)->attr('data-autocomplete-value')); - $this->assertSame('Le Mesnil-Esnard (76240)', $li->eq(1)->text()); - $this->assertSame('76429', $li->eq(1)->attr('data-autocomplete-value')); + $this->assertSame('Le Mesnil-Esnard (76240)', $options->eq(1)->text()); + $this->assertSame('76429', $options->eq(1)->attr('data-autocomplete-value')); - $this->assertSame('Le Mesnil-le-Roi (78600)', $li->eq(2)->text()); - $this->assertSame('78396', $li->eq(2)->attr('data-autocomplete-value')); + $this->assertSame('Le Mesnil-le-Roi (78600)', $options->eq(2)->text()); + $this->assertSame('78396', $options->eq(2)->attr('data-autocomplete-value')); + } + + public function testCityAutoCompleteNoResults(): void + { + $client = $this->login(); + $crawler = $client->request('GET', '/_fragment/city-completions?search=BlahBlah'); + + $this->assertResponseStatusCodeSame(200); + $this->assertSecurityHeaders(); + + $this->assertSame('0 résultat trouvé', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(0, $options->count()); } public function testBadRequest(): void diff --git a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetDepartmentalRoadCompletionFragmentControllerTest.php b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetDepartmentalRoadCompletionFragmentControllerTest.php index 9e44ee9c9..1e0d5be60 100644 --- a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetDepartmentalRoadCompletionFragmentControllerTest.php +++ b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetDepartmentalRoadCompletionFragmentControllerTest.php @@ -16,13 +16,14 @@ public function testDepartmentalRoadAutoComplete(): void $this->assertResponseStatusCodeSame(200); $this->assertSecurityHeaders(); - $li = $crawler->filter('li'); - $this->assertSame(4, $li->count()); - - $this->assertSame('D32', $li->eq(0)->text()); - $this->assertSame('D322', $li->eq(1)->text()); - $this->assertSame('D322A', $li->eq(2)->text()); - $this->assertSame('D324', $li->eq(3)->text()); + $this->assertSame('4 résultats trouvés', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(4, $options->count()); + + $this->assertSame('D32', $options->eq(0)->text()); + $this->assertSame('D322', $options->eq(1)->text()); + $this->assertSame('D322A', $options->eq(2)->text()); + $this->assertSame('D324', $options->eq(3)->text()); } public function testBadRequest(): void diff --git a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetIntersectionCompletionFragmentControllerTest.php b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetIntersectionCompletionFragmentControllerTest.php index 6387df451..c87f4f127 100644 --- a/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetIntersectionCompletionFragmentControllerTest.php +++ b/tests/Integration/Infrastructure/Controller/Regulation/Fragments/GetIntersectionCompletionFragmentControllerTest.php @@ -16,10 +16,11 @@ public function testIntersectionsAutoComplete(): void $this->assertResponseStatusCodeSame(200); $this->assertSecurityHeaders(); - $li = $crawler->filter('li'); - $this->assertSame(2, $li->count()); - $this->assertSame('Boulevard Morland', $li->eq(0)->text()); - $this->assertSame('Quai Henri Iv', $li->eq(1)->text()); + $this->assertSame('2 résultats trouvés', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(2, $options->count()); + $this->assertSame('Boulevard Morland', $options->eq(0)->text()); + $this->assertSame('Quai Henri Iv', $options->eq(1)->text()); } public function testIntersectionsAutoCompleteWithSearch(): void @@ -30,9 +31,10 @@ public function testIntersectionsAutoCompleteWithSearch(): void $this->assertResponseStatusCodeSame(200); $this->assertSecurityHeaders(); - $li = $crawler->filter('li'); - $this->assertSame(1, $li->count()); - $this->assertSame('Boulevard Morland', $li->eq(0)->text()); + $this->assertSame('1 résultat trouvé', $crawler->filter('template[id="status"]')->text()); + $options = $crawler->filter('li[role="option"]'); + $this->assertSame(1, $options->count()); + $this->assertSame('Boulevard Morland', $options->eq(0)->text()); } private function provideTestBadRequest(): array diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 70f4bcedb..4b898e7c5 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -168,6 +168,18 @@ common.form.validate Valider + + common.autocomplete.results_count + %count% résultat trouvé|%count% résultats trouvés + + + common.autocomplete.status.loading + Chargement en cours... + + + common.autocomplete.status.min_chars + Commencez à saisir au moins %minChars% caractères + common.date.from du %date%