@@ -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