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:
commit
7e9c63c684
16 changed files with 146 additions and 17 deletions
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
|
||||
def initialize(**args)
|
||||
super(**args)
|
||||
|
||||
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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 }
|
||||
|
|
17
app/components/editable_champ/combo_search_component.rb
Normal file
17
app/components/editable_champ/combo_search_component.rb
Normal 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
|
|
@ -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)
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
class EditableChamp::CommunesComponent < EditableChamp::ComboSearchComponent
|
||||
end
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}.*'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -58,6 +58,12 @@ fr:
|
|||
skiplinks:
|
||||
quick: Accès rapide
|
||||
content: Contenu
|
||||
combo_search_component:
|
||||
screen_reader_instructions: "Quand les résultats de l’autocomplete 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:
|
||||
|
|
Loading…
Reference in a new issue