Skip to content

Commit acf4e8e

Browse files
authored
Merge pull request #1249 from betagouv/fix_mobile_display
Fix mobile display
2 parents 4592b06 + 96f65d9 commit acf4e8e

3 files changed

Lines changed: 87 additions & 16 deletions

File tree

src/components/Ressources/Understanding.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type Document, understandings } from './config';
44

55
const Understanding = ({ cards }: { cards?: Record<string, Document> }) => {
66
return (
7-
<div className="flex gap-4">
7+
<div className="flex gap-4 flex-wrap">
88
{Object.entries(cards || understandings).map(([key, understanding]) => (
99
<Card
1010
className="flex-1"

src/modules/form/Autocomplete.spec.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,47 @@ describe('Autocomplete', () => {
156156
expect(screen.queryByRole('listbox')).toBeNull();
157157
});
158158

159+
it('rouvre les résultats déjà chargés au focus, sans nouveau fetch', async () => {
160+
render(<Autocomplete {...defaultProps} />);
161+
const input = screen.getByRole('combobox');
162+
163+
await typeAndWaitForResults(input, 'Par');
164+
expect(screen.getByRole('listbox')).toBeDefined();
165+
expect(defaultFetchFn).toHaveBeenCalledOnce();
166+
167+
// Fermeture (équivalent d'un clic en dehors)
168+
await act(async () => {
169+
fireEvent.keyDown(input, { key: 'Escape' });
170+
});
171+
expect(screen.queryByRole('listbox')).toBeNull();
172+
173+
// Le focus rouvre les résultats mémorisés sans relancer fetchFn
174+
await act(async () => {
175+
fireEvent.focus(input);
176+
});
177+
expect(screen.getByRole('listbox')).toBeDefined();
178+
expect(screen.getByText('Paris')).toBeDefined();
179+
expect(defaultFetchFn).toHaveBeenCalledOnce();
180+
});
181+
182+
it("affiche le message d'erreur dans le dropdown quand le fetch échoue", async () => {
183+
const failingFetch = vi.fn(async (): Promise<Option[]> => {
184+
throw new Error('Network down');
185+
});
186+
const { container } = render(<Autocomplete {...defaultProps} fetchFn={failingFetch} errorMessage="Oups, réessayez" />);
187+
const input = screen.getByRole('combobox');
188+
189+
await typeAndWaitForResults(input, 'Par');
190+
191+
expect(screen.getByRole('alert')).toBeDefined();
192+
expect(screen.getByText('Oups, réessayez')).toBeDefined();
193+
expect(screen.queryByRole('listbox')).toBeNull();
194+
// isRunning doit être repassé à false → le picto d'alerte (SVG inline) doit s'afficher.
195+
// SVG inline = pas de dépendance réseau (les icônes DSFR chargent leur glyphe via mask-image).
196+
expect(input.getAttribute('aria-busy')).toBe('false');
197+
expect(container.querySelector('svg')).not.toBeNull();
198+
});
199+
159200
it('navigue avec ArrowDown/ArrowUp et sélectionne avec Enter', async () => {
160201
render(<Autocomplete {...defaultProps} />);
161202
const input = screen.getByRole('combobox');

src/modules/form/Autocomplete.tsx

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type AutocompleteProps<Option> = {
2828
className?: string;
2929
/** Message affiché quand la recherche aboutit avec 0 résultat. Défaut : "Aucun résultat". */
3030
emptyMessage?: React.ReactNode;
31+
/** Message affiché dans le dropdown quand la recherche échoue (réseau/serveur). Défaut : message générique. */
32+
errorMessage?: React.ReactNode;
3133
nativeInputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'>;
3234
};
3335

@@ -53,6 +55,7 @@ export function Autocomplete<Option>({
5355
id: idProp,
5456
className,
5557
emptyMessage = 'Aucun résultat',
58+
errorMessage = 'La recherche a échoué, veuillez réessayer.',
5659
nativeInputProps,
5760
}: AutocompleteProps<Option>) {
5861
const generatedId = useId();
@@ -66,6 +69,10 @@ export function Autocomplete<Option>({
6669
const [searchQuery, setSearchQuery] = useState('');
6770
const [suggestions, setSuggestions] = useState<Option[]>([]);
6871
const [hasNoResults, setHasNoResults] = useState(false);
72+
// Open state is decoupled from the presence of suggestions so the dropdown can be
73+
// closed (click outside, Escape) while keeping the last results in memory, then
74+
// reopened on focus without re-fetching.
75+
const [isOpen, setIsOpen] = useState(false);
6976
const [highlightedIndex, setHighlightedIndex] = useState(-1);
7077
const [fetchError, setFetchError] = useState<string | null>(null);
7178
const [anchorWidth, setAnchorWidth] = useState<number | undefined>(undefined);
@@ -105,6 +112,7 @@ export function Autocomplete<Option>({
105112
setHighlightedIndex(-1);
106113
setSearchQuery('');
107114
setFetchError(null);
115+
setIsOpen(false);
108116
}
109117
// eslint-disable-next-line react-hooks/exhaustive-deps
110118
}, [value]); // cancel is stable, omitted intentionally
@@ -120,12 +128,15 @@ export function Autocomplete<Option>({
120128
setFetchError(error.message);
121129
setSuggestions([]);
122130
setHasNoResults(false);
131+
// Open the dropdown so the error is readable (the alert icon's tooltip is unusable on touch).
132+
setIsOpen(true);
123133
},
124134
onSuccess: (results) => {
125135
setSuggestions(results);
126136
setHasNoResults(results.length === 0);
127137
setHighlightedIndex(-1);
128138
setFetchError(null);
139+
setIsOpen(true);
129140
},
130141
});
131142

@@ -145,6 +156,7 @@ export function Autocomplete<Option>({
145156
setDisplayValue(optionValue);
146157
setSearchQuery('');
147158
setFetchError(null);
159+
setIsOpen(false);
148160
onChange?.(optionValue);
149161
onSelect(option);
150162
};
@@ -161,22 +173,23 @@ export function Autocomplete<Option>({
161173
cancel();
162174
setSuggestions([]);
163175
setHasNoResults(false);
176+
setIsOpen(false);
164177
}
165178
};
166179

180+
// Close the dropdown but keep suggestions/hasNoResults in memory so focus can reopen them.
167181
const closePopover = () => {
168-
setSuggestions([]);
169-
setHasNoResults(false);
182+
setIsOpen(false);
170183
setHighlightedIndex(-1);
171184
};
172185

173186
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
174-
if ((e.key === 'Escape' || e.key === 'Tab') && (suggestions.length || hasNoResults)) {
187+
if ((e.key === 'Escape' || e.key === 'Tab') && isOpen) {
175188
if (e.key === 'Escape') e.preventDefault();
176189
closePopover();
177190
return;
178191
}
179-
if (!suggestions.length) return;
192+
if (!isOpen || !suggestions.length) return;
180193
switch (e.key) {
181194
case 'ArrowDown':
182195
e.preventDefault();
@@ -203,6 +216,7 @@ export function Autocomplete<Option>({
203216
setDisplayValue('');
204217
setSearchQuery('');
205218
setFetchError(null);
219+
setIsOpen(false);
206220
onChange?.('');
207221
onClear?.();
208222
inputRef.current?.focus();
@@ -215,7 +229,14 @@ export function Autocomplete<Option>({
215229
closePopover();
216230
};
217231

218-
const isOpen = suggestions.length > 0 || hasNoResults;
232+
// Reopen the previously fetched results when the user returns to the field
233+
// (e.g. after clicking outside or pressing Escape), without re-querying.
234+
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
235+
nativeInputProps?.onFocus?.(e);
236+
if (suggestions.length > 0 || hasNoResults || fetchError !== null) {
237+
setIsOpen(true);
238+
}
239+
};
219240

220241
return (
221242
<div className={className}>
@@ -236,6 +257,7 @@ export function Autocomplete<Option>({
236257
onChange={handleInputChange}
237258
onKeyDown={handleKeyDown}
238259
{...nativeInputProps}
260+
onFocus={handleFocus}
239261
autoComplete="off"
240262
className={cx('pr-10 text-ellipsis', nativeInputProps?.className)}
241263
/>
@@ -246,18 +268,21 @@ export function Autocomplete<Option>({
246268
width={16}
247269
color="var(--text-default-grey)"
248270
secondaryColor="var(--text-default-grey)"
249-
wrapperClass="absolute top-1/2 -translate-y-1/2 right-[calc(16px+1.5rem)] z-10"
271+
wrapperClass="absolute top-1/2 -translate-y-1/2 right-10 z-10"
250272
/>
251273
)}
252274

253275
{fetchError && !isRunning && (
254-
<Icon
255-
name="ri-alert-line"
256-
size="sm"
257-
color="var(--text-default-error)"
258-
title={fetchError}
259-
className="absolute top-1/2 -translate-y-1/2 right-[calc(16px+1.5rem)] z-10"
260-
/>
276+
// ri-alert-line inlined: DSFR icons fetch their glyph via mask-image (network), which fails
277+
// offline — exactly when this error icon is needed. Decorative (message announced by the alert).
278+
<svg
279+
aria-hidden
280+
viewBox="0 0 24 24"
281+
fill="currentColor"
282+
className="absolute top-1/2 -translate-y-1/2 right-10 z-10 size-4 text-(--text-default-error)"
283+
>
284+
<path d="M12.8659 3.00017L22.3922 19.5002C22.6684 19.9785 22.5045 20.5901 22.0262 20.8662C21.8742 20.954 21.7017 21.0002 21.5262 21.0002H2.47363C1.92135 21.0002 1.47363 20.5525 1.47363 20.0002C1.47363 19.8246 1.51984 19.6522 1.60761 19.5002L11.1339 3.00017C11.41 2.52187 12.0216 2.358 12.4999 2.63414C12.6519 2.72191 12.7782 2.84815 12.8659 3.00017ZM4.20568 19.0002H19.7941L11.9999 5.50017L4.20568 19.0002ZM10.9999 16.0002H12.9999V18.0002H10.9999V16.0002ZM10.9999 9.00017H12.9999V14.0002H10.9999V9.00017Z" />
285+
</svg>
261286
)}
262287

263288
{displayValue ? (
@@ -291,14 +316,19 @@ export function Autocomplete<Option>({
291316
style={{ width: anchorWidth ? `${anchorWidth}px` : undefined }}
292317
>
293318
<div role="status" aria-live="polite" className="sr-only">
294-
{isOpen
319+
{/* Error is announced by the visible role="alert" below, not here */}
320+
{isOpen && !fetchError
295321
? suggestions.length > 0
296322
? `${suggestions.length} résultat${suggestions.length > 1 ? 's' : ''}`
297323
: 'Aucun résultat'
298324
: ''}
299325
</div>
300326
<div className="bg-(--background-default-grey) border border-(--border-default-grey) shadow-[0_4px_8px_rgba(0,0,0,0.12)]">
301-
{suggestions.length > 0 ? (
327+
{fetchError ? (
328+
<div role="alert" className="text-sm py-2 px-3 text-(--text-default-error)">
329+
{errorMessage}
330+
</div>
331+
) : suggestions.length > 0 ? (
302332
<ul
303333
id={listboxId}
304334
role="listbox"

0 commit comments

Comments
 (0)