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 %}
+
+
+ {{ 'common.autocomplete.results_count'|trans({ '%count%': cities|length }) }}
+
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 %}
+
+
+ {{ 'common.autocomplete.results_count'|trans({ '%count%': roadNames|length }) }}
+
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 %}
+
+
+ {{ 'common.autocomplete.results_count'|trans({ '%count%': roadNames|length }) }}
+
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 %}
+
+
+ {{ 'common.autocomplete.results_count'|trans({ '%count%': departmentalRoadNumbers|length }) }}
+
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 @@
Valider
+
+
+ %count% résultat trouvé|%count% résultats trouvés
+
+
+
+ Chargement en cours...
+
+
+
+ Commencez à saisir au moins %minChars% caractères
+
du %date%