Merge pull request #8677 from colinux/a11y-combobox-announce-results

a11y(combosearch): instructions pour lecteurs d'écran et annonce l'arrivée des résultats
This commit is contained in:
Colin Darie 2023-03-01 08:46:39 +00:00 committed by GitHub
commit 7e9c63c684
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 146 additions and 17 deletions

View file

@ -1,3 +1,2 @@
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,6 +1,11 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= react_component("ComboAdresseSearch",
required: @champ.required?,
id: @champ.input_id,
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props,
)

View file

@ -1,3 +1,2 @@
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,6 +1,9 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= react_component("ComboAnnuaireEducationSearch",
required: @champ.required?,
id: @champ.input_id,
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props)

View file

@ -1,3 +1,9 @@
class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
def initialize(**args)
super(**args)
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
end
end

View file

@ -1,4 +1,11 @@
= react_component("MapEditor", featureCollection: @champ.to_feature_collection, url: champs_carte_features_path(@champ), options: @champ.render_options)
= render @autocomplete_component
= react_component("MapEditor",
featureCollection: @champ.to_feature_collection,
url: champs_carte_features_path(@champ),
options: @champ.render_options,
autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id,
autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions"))
.geo-areas{ id: dom_id(@champ, :geo_areas) }
= render partial: 'shared/champs/carte/geo_areas', locals: { champ: @champ, editing: true }

View file

@ -0,0 +1,17 @@
class EditableChamp::ComboSearchComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
def announce_template_id
@announce_template_id ||= dom_id(@champ, "aria-announce-template")
end
# NOTE: because this template is called by `render_parent` from a child template,
# as of ViewComponent 2.x translations virtual paths are not properly propagated
# and we can't use the usual component namespacing. Instead we use global translations.
def react_combo_props
{
screenReaderInstructions: t("combo_search_component.screen_reader_instructions"),
announceTemplateId: announce_template_id
}
end
end

View file

@ -0,0 +1,4 @@
%template{ id: announce_template_id }
%slot{ "name": "0" }= t("combo_search_component.result_slot_html", count: 0)
%slot{ "name": "1" }= t("combo_search_component.result_slot_html", count: 1)
%slot{ "name": "many" }= t("combo_search_component.result_slot_html", count: 2)

View file

@ -1,3 +1,2 @@
class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
class EditableChamp::CommunesComponent < EditableChamp::ComboSearchComponent
end

View file

@ -1,3 +1,4 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= @form.hidden_field :departement
@ -7,4 +8,5 @@
id: @champ.input_id,
classNameDepartement: "width-33-desktop width-100-mobile",
className: "width-66-desktop width-100-mobile",
describedby: @champ.describedby_id)
describedby: @champ.describedby_id,
**react_combo_props)

View file

@ -1,4 +1,10 @@
import React, { useState, useRef, ChangeEventHandler } from 'react';
import React, {
useState,
useEffect,
useRef,
useId,
ChangeEventHandler
} from 'react';
import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query';
import {
@ -18,7 +24,7 @@ type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result> = {
export type ComboSearchProps<Result = unknown> = {
onChange?: (value: string | null, result?: Result) => void;
value?: string;
scope: string;
@ -32,6 +38,8 @@ export type ComboSearchProps<Result> = {
className?: string;
placeholder?: string;
debounceDelay?: number;
screenReaderInstructions: string;
announceTemplateId: string;
};
type QueryKey = readonly [
@ -51,6 +59,8 @@ function ComboSearch<Result>({
transformResults = (_, results) => results as Result[],
id,
describedby,
screenReaderInstructions,
announceTemplateId,
debounceDelay = 0,
...props
}: ComboSearchProps<Result>) {
@ -127,6 +137,46 @@ function ComboSearch<Result>({
}
};
const [announceLive, setAnnounceLive] = useState('');
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
const announceTemplate = document.querySelector<HTMLTemplateElement>(
`#${announceTemplateId}`
);
invariant(announceTemplate, `Missing #${announceTemplateId}`);
const announceFragment = useRef(
announceTemplate.content.cloneNode(true) as DocumentFragment
).current;
useEffect(() => {
if (isSuccess) {
const slot = announceFragment.querySelector<HTMLSlotElement>(
'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]'
);
if (!slot) {
return;
}
const countSlot =
slot.querySelector<HTMLSlotElement>('slot[name="count"]');
if (countSlot) {
countSlot.replaceWith(String(results.length));
}
setAnnounceLive(slot.textContent ?? '');
}
announceTimeout.current = setTimeout(() => {
setAnnounceLive('');
}, 3000);
return () => clearTimeout(announceTimeout.current);
}, [announceFragment, results.length, isSuccess]);
const initInstrId = useId();
const resultsId = useId();
return (
<Combobox onSelect={handleOnSelect}>
<ComboboxInput
@ -136,10 +186,11 @@ function ComboSearch<Result>({
value={value ?? ''}
autocomplete={false}
id={id}
aria-describedby={describedby}
aria-describedby={describedby ?? initInstrId}
aria-owns={resultsId}
/>
{isSuccess && (
<ComboboxPopover className="shadow-popup">
<ComboboxPopover id={resultsId} className="shadow-popup">
{results.length > 0 ? (
<ComboboxList>
{results.map((result, index) => {
@ -156,6 +207,14 @@ function ComboSearch<Result>({
)}
</ComboboxPopover>
)}
{!describedby && (
<span id={initInstrId} className="hidden">
{screenReaderInstructions}
</span>
)}
<div aria-live="assertive" className="sr-only">
{announceLive}
</div>
</Combobox>
);
}

View file

@ -2,8 +2,14 @@ import React from 'react';
import { fire } from '@utils';
import ComboAdresseSearch from '../../ComboAdresseSearch';
import { ComboSearchProps } from '~/components/ComboSearch';
export function AddressInput() {
export function AddressInput(
comboProps: Pick<
ComboSearchProps,
'screenReaderInstructions' | 'announceTemplateId'
>
) {
return (
<div
style={{
@ -17,6 +23,7 @@ export function AddressInput() {
onChange={(_, feature) => {
fire(document, 'map:zoom', { feature });
}}
{...comboProps}
/>
</div>
);

View file

@ -12,15 +12,20 @@ import { AddressInput } from './components/AddressInput';
import { PointInput } from './components/PointInput';
import { ImportFileInput } from './components/ImportFileInput';
import { FlashMessage } from '../shared/FlashMessage';
import { ComboSearchProps } from '../ComboSearch';
export default function MapEditor({
featureCollection: initialFeatureCollection,
url,
options
options,
autocompleteAnnounceTemplateId,
autocompleteScreenReaderInstructions
}: {
featureCollection: FeatureCollection;
url: string;
options: { layers: string[] };
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
}) {
const [cadastreEnabled, setCadastreEnabled] = useState(false);
@ -46,7 +51,10 @@ export default function MapEditor({
{error && <FlashMessage message={error} level="alert" fixed={true} />}
<ImportFileInput featureCollection={featureCollection} {...actions} />
<AddressInput />
<AddressInput
screenReaderInstructions={autocompleteScreenReaderInstructions}
announceTemplateId={autocompleteAnnounceTemplateId}
/>
<MapLibre layers={options.layers}>
<DrawLayer

View file

@ -110,6 +110,8 @@ ignore_unused:
- 'instructeurs.dossiers.filterable_state.*'
- 'views.prefill_descriptions.edit.possible_values.*'
- 'helpers.page_entries_info.*'
- 'combo_search_component.result_slot_html.*'
- 'combo_search_component.screen_reader_instructions'
# - '{devise,kaminari,will_paginate}.*'
# - 'simple_form.{yes,no}'
# - 'simple_form.{placeholders,hints,labels}.*'

View file

@ -67,6 +67,12 @@ en:
skiplinks:
quick: Quick access
content: Content
combo_search_component:
screen_reader_instructions: "When autocomplete results are available, use the up and down arrows to navigate through the list of results. Press Enter to select a result. Press Escape to close the results list."
result_slot_html:
zero: No result
one: 1 result
other: <slot name="count"></slot> results
layouts:
commencer:
no_procedure:

View file

@ -58,6 +58,12 @@ fr:
skiplinks:
quick: Accès rapide
content: Contenu
combo_search_component:
screen_reader_instructions: "Quand les résultats de lautocomplete sont disponibles, utilisez les flèches haut et bas pour naviguer dans la liste des résultats. Appuyez sur Entrée pour sélectionner un résultat. Appuyez sur Echap pour fermer la liste des résultats."
result_slot_html:
zero: Aucun résultat
one: 1 résultat
other: <slot name="count"></slot> résultats
layouts:
commencer:
no_procedure: