Apprendre à réaliser un Typeahead « maison » avec React hooks

Frédéric Fanchamps

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Apprendre à réaliser un Typeahead « maison » avec React hooks

- Auteur : Frédéric Fanchamps

- Parution : 24 octobre 2022

- Licence : Copyright ® 2022 Frédéric Fanchamps. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Sans aucun package autre que create-react-app

- II. Créer l’application

- III. L’embryon de Typeahead

- IV. Premier appel à l’API

- V. Prochains todos

- VI. Todo 1 : les suggestions doivent coller à la recherche

- VII. Todo 2 : les suggestions doivent s’afficher comme un menu déroulant

- VIII. Todo 3A : on peut choisir une suggestion avec le clavier

- VIII-A. étape 1 : notion de suggestion sélectionnée

- VIII-B. étape 2 : changer la suggestion sélectionnée avec le clavier

- VIII-C. étape 3 : utiliser la suggestion sélectionnée

- IX. Todo 3B : on peut choisir une suggestion avec la souris

- X. Todo 4 : le composant doit être indépendant de l’API

- XI. Todo 5 : quitter le champ sans faire de choix est une erreur

- XII. La touche finale

- XIII. Bonus

- XIV. Pour le lecteur attentif

- XV. Remerciements Developpez.com

I - Sans aucun package autre que create-react-app

Le but de ce tutoriel est de réaliser un Typeahead maison sur la base des packages disponibles après création de votre application avec create-react-app et rien d’autre.

L’objectif de ce guide n’est pas d’expliquer React ni node.js, mais en le suivant avec l’esprit un peu curieux et la doc React hooks (qui n’est pas très longue et très bien faite https://reactjs.org/docs/hooks-intro.html) à portée de la main, c’est une manière assez efficace (je trouve) d‘apprendre ces notions.

Pourquoi un Typeahead ? Parce que le comportement attendu de ce genre de composants est connu de tous, il est suffisamment complexe pour aborder toutes sortes de notions, mais pas trop pour pouvoir être réalisé en quelques heures. De plus, chacun peut avoir un avis ou une idée sur ce qu’il désire ajouter, c’est une bonne base pour « jouer » à React.

L’environnement utilisé est node.js et Visual Studio Code, muni de l’extension (optionnelle) Prettier. Nous ne réaliserons pas un typeahead universel qui pourrait être publié comme un package à tout faire (quoique), mais une base de code que vous comprendrez de A à Z et pourrez librement étendre et modifier selon vos besoins. L’OS utilisé dans les captures d’écrans est Windows et le browser est Chromen mais cela ne devrait pas avoir d’importance.

Ce tutoriel n’a pas la prétention d’être la 2.342.839e ressource qui explique comment installer Visual Studio Code ou node.js, ce sont des prérequis. Tout le code est disponible à l’adresse https://github.com/frfancha/Typeahead, sous forme de commits progressifs suivant l’avancement du tutoriel. Finalement, ce document contient des captures d’écran desquelles il n’est donc pas possible de faire copy/paste. On peut le faire depuis le repository github, mais franchement, retaper soi-même le code, changer les noms des variables pour être sûr qu’on a bien compris, expérimenter des variations… Cela vaut vraiment la peine par rapport à un simple copy/paste qui n’apporte rien de plus qu’une lecture passive. Finalement, vous l’aurez compris à la lecture des mots « copy/paste », je ne suis pas tellement fanatique de la francisation à tout va.

II - Créer l’application

Dans le dossier où nous désirons créer notre application (appelons-la Typeahead) tapons npx create-react-app Typeahead, puis éditons l’application avec code :

III - L’embryon de Typeahead

Ajoutons un Typeahead (qui n’en est pas encore un !), mais juste un simple input.

Nouveau fichier Typeahead.js dans le dossier src :

1. import React, { useState } from "react";

2. const Typeahead = props => {

3. const [inputValue, setInputValue] = useState("");

4. const onChange = e => {

5. setInputValue(e.target.value);

6. };

7. return (

8. <div>

9. <input value={inputValue} onChange={onChange} />

10. </div>

11. );

12. };

13. export default Typeahead;

Et mettons-en deux dans le App.js (en ayant donc préalablement supprimé tout le code généré par défaut par create-react-app) :

1. import React from "react";

2. import "./App.css";

3. import Typeahead from "./Typeahead";

5. function App() {

6. return (

7. <div className="App">

8. <label>Un Typeahead:</label>

9. <Typeahead></Typeahead>

10. <label>Et un deuxième:</label>

11. <Typeahead></Typeahead>

12. </div>

13. );

14. }

16. export default App;

C’est le grand moment, on va démarrer l’application en ouvrant une console depuis Visual Studio Code en tapant npm start:

Ce qui donne le résultat suivant :

Dans lequel on peut taper du texte :

En effet, le travail n’a pas encore vraiment commencé…

IV - Premier appel à l’API

Utilisons à titre d’exemple https://reststop.randomhouse.com/resources/works?search= une recherche sur les livres publiés par Penguin. Voir http://www.penguinrandomhouse.biz/webservices/rest/#works pour la documentation de cette API.

Cette API ne retourne pas du json par défaut (c’est de l’XML), il faudra forcer le retour json avec le http header « Accept ». Si un seul ouvrage est trouvé, il est dans la propriété « work » de l’objet renvoyé, s’il y a plusieurs ouvrages, l’API nous renvoie également un attribut « work », mais qui contient un tableau d’ouvrages.

Pour réussir à appeler une API qui ne vient pas du site de développement sans être bloqué par CORS, il y a plusieurs solutions. Nous allons utiliser la plus simple : juste mettre le site https://reststop.randomhouse.com dans la propriété proxy du package.json. De cette façon, toutes les requêtes que ne connaît pas notre serveur de développement sont simplement envoyées à ce serveur. Cela ne convient que pour des cas élémentaires, mais cela fait parfaitement l’affaire ici. L’alternative souvent utilisée dans les tutoriels sur les Typeahead consiste à coder en dur une liste de pays dans l’application et à la servir filtrée sur le critère de recherche avec un setTimeout aléatoire pour simuler le réseau. Je n’aime pas cette façon de faire, comme ce n’est pas un vrai fetch, « on n’y croit pas » (point de vue personnel bien sûr). Il serait également impossible de parler de la façon d’annuler des fetch en cours sans utiliser une vraie API.

OK, assez parlé, place au code. De quoi sauver les suggestions :

1. import React, { useState } from "react";

2. const Typeahead = props => {

3. const [inputValue, setInputValue] = useState("");

4. const [suggestions, setSuggestions] = useState([]);

5. const onChange = e => {

Ce code permet d’aller chercher les ouvrages quand inputValue n’est pas vide. Contentons-nous pour le moment d’utiliser comme suggestion la chaîne de caractères dans l’attribut titleAuth des ouvrages retournés. La seule « difficulté » c’est de distinguer les cas où un seul ouvrage est reçu dans work, des cas où work contient un tableau :

1. const onChange = e => {

2. setInputValue(e.target.value);

3. const value = e.target.value;

4. setInputValue(value);

5. if (value) {

6. const f = async () => {

7. let query = "/resources/works?search=" + encodeURIComponent(value);

8. try {

9. const fetchResult = await fetch(query, {

10. headers: {

11. Accept: "application/json"

12. }

13. });

14. const result = await fetchResult.json();

15. if (result.work) {

16. // if we have a work property

17. if (result.work.length) {

18. // is this an array?

19. // use the titleAuth as display

20. setSuggestions(result.work.map(v => v.titleAuth));

21. } else if (result.work.titleAuth) {

22. // or a single item

23. setSuggestions([result.work.titleAuth]);

24. }

25. }

26. } catch (err) {

27. // handle other errors here

28. console.error(err);

29. return;

30. }

31. };

32. f();

33. }

34. };

Affichons les suggestions. Il n’y a pas vraiment de clef dans l’API Penguin, ou en tout cas elle n’est pas formellement définie. Utilisons donc simplement l’index du tableau comme clef.

1. return (

2. <div>

3. <input value={inputValue} onChange={onChange} />

4. {suggestions.map((s, i) => (

5. <div key={i}>{s}</div>

6. ))}

7. </div>

8. );

Et la modification pour le proxy :

1. "test": "react-scripts test",

2. "eject": "react-scripts eject"

3. },

4. "proxy": "https://reststop.randomhouse.com",

5. "eslintConfig": {

6. "extends": "react-app"

7. },

Et, oui cela fonctionne :

V - Prochains todos

Bien sûr, c’est très excitant pour un développeur de voir que quelque chose s’affiche (surtout si c’est votre première application React), mais en réalité, nous ne sommes pas encore très loin, pour ne pas dire nulle part. Que faut-il ajouter au minimum :

1. Quand l’API revient, il n’y a aucun test que ce qui est affiché est toujours ce qui a servi à lancer la recherche ;

2. Les suggestions prennent bêtement de la place dans le DOM en dessous de l’input, elles doivent s’afficher comme une liste déroulante ;

3. Il n’y a aucun moyen de choisir l’une des suggestions ;

4. L’URL de fetch et la manière d’extraire le champ qui s’affiche dans les suggestions doivent être des paramètres du Typeahead et pas codés en dur ;

5. Lorsque l’utilisateur quitte le champ alors qu’il n’a pas choisi une suggestion, le champ est « en erreur » et cela doit se voir.

Occupons-nous de cela.

VI - Todo 1 : les suggestions doivent coller à la recherche

Il y a plusieurs stratégies possibles :

- simplement vérifier que l’inputValue est toujours celle qui a servi à lancer la recherche au moment où l’on reçoit les résultats et ignorer ceux-ci si ce n’est pas le cas ;

- annuler la recherche en cours chaque fois que l’inputValue change ;

- conserver un objet avec les suggestions indexées par texte de recherche. De cette façon toute recherche est potentiellement « utile », on n’utilise que celle dont l’input correspond à l’input courant. Si l’utilisateur tape A on cherche pour A et on affiche les résultats. Puis il tape B, on cherche AB et on affiche les résultats. Quand l’utilisateur efface le dernier caractère (B) la recherche concerne de nouveau A, on ne lance plus de fetch, car les suggestions pour « A » sont déjà connues, elles sont directement affichées.

C’est cette dernière solution que nous allons implémenter.

Tout d’abord, nous n’avons plus un tableau de suggestions, mais bien un objet. Les attributs de l’objet seront les termes pour lesquels une recherche a été lancée. La valeur d’un attribut sera la liste des suggestions correspondant à cette clef. La présence d’un attribut pour une clef donnée permet donc de savoir si la recherche a déjà été faite ou pas pour ces caractères.

Pour nous aider à comprendre et vérifier le fonctionnement, on va afficher l’état des suggestions quand l’inputValue change :

1. const onChange = e => {

2. const value = e.target.value;

3. setInputValue(value);

4. // console log the current "state" of the suggestions:

5. console.log(

6. "already searched: " +

7. Object.keys(suggestions)

8. .map(

9. k =>

10. k + "(" + (suggestions[k] ? suggestions[k].length : "null") + ")"

11. )

12. .join(", ")

13. );

Quand il faut faire une recherche (l’inputValue n’est pas vide), on vérifie d’abord que celle-ci n’est pas déjà commencée, la clef doit retourner « undefined ». Si c’est le cas, on la marque immédiatement comme valant « null » (qui est différent de undefined) pour retenir qu’un fetch a démarré pour cette recherche et qu’il ne faut plus en démarrer d’autre. Sans passer par setSuggestions vu qu’un render est inutile… certains puristes vont peut-être crier.

1. if (value) {

2. // undefined = no search has ever been made on that string

3. if (suggestions[value] === undefined) {

4. // just to check that we fetch only once by text:

5. console.log("search for " + value);

6. // mark null to avoid launching another search on same key

7. suggestions[value] = null;

8. const f = async () => {

À la réception des résultats, on les ajoute à l’objet des suggestions. Comme le setSuggestions devient un tout petit plus complexe, sortons-le du bloc [if tableau if single] qui va se contenter de préparer les suggestions reçues dans foundSuggestions. On fait un seul appel à setSuggestions après le bloc [if tableau if single] :

1. let foundSuggestions;

2. // if we have a work property

3. if (result.work.length) {

4. // is this an array?

5. // use the titleAuth as display

6. foundSuggestions = result.work.map(v => v.titleAuth);

7. } else if (result.work.titleAuth) {

8. // or a single item

9. foundSuggestions = [result.work.titleAuth];

10. }

11. if (foundSuggestions) {

12. setSuggestions(v => ({ ...v, [value]: foundSuggestions }));

13. }

14. }

Et finalement, dans le DOM, on utilise les suggestions en accord avec l’inputValue, s’il y en a :

1. return (

2. <div>

3. <input value={inputValue} onChange={onChange} />

4. {suggestions.map((s, i) => (

5. <div key={i}>{s}</div>

6. ))}

7. {suggestions[inputValue]

8. ? suggestions[inputValue].map((s, i) => <div key={i}>{s}</div>)

9. : null}

10. </div>

11. );

12. };

Prouvons que tout cela fonctionne admirablement bien, on tape John dans l’input text :

Voyez la console : à la recherche de J, rien n’est encore dans suggestions.

À la recherche de Jo, J est dans les suggestions, mais encore avec null : le résultat pour J n’est pas encore arrivé, mais J est bien marqué comme ayant démarré un fetch.

Même chose pour Joh et John.

Ensuite, le résultat de John est arrivé et s’affiche dans le DOM.

Effaçons le n pour forcer à réafficher l’état des suggestions dans la console :

L’affichage des suggestions pour Joh est instantané (il n’y a pas de fetch) et l’on voit dans la console que les résultats (chaque fois 10 lignes) sont maintenant bien arrivés pour J, Jo, Joh et John.

VII - Todo 2 : les suggestions doivent s’afficher comme un menu déroulant

On va d’abord effacer complètement le contenu de App.css qui contient les valeurs par défaut de create-react-app (plus exactement du template par défaut de cette commande) et dont nous n’avons pas besoin.

Pour mettre en évidence la zone du « App », mettons juste un petit border :

1. .App {

2. text-align: center;

3. }

5. .App-logo {

6. height: 40vmin;

7. pointer-events: none;

8. }

10. @media (prefers-reduced-motion: no-preference) {

11. .App-logo {

12. animation: App-logo-spin infinite 20s linear;

13. }

14. }

16. .App-header {

17. background-color: #282c34;

18. min-height: 100vh;

19. display: flex;

20. flex-direction: column;

21. align-items: center;

22. justify-content: center;

23. font-size: calc(10px + 2vmin);

24. color: white;

25. }

27. .App-link {

28. color: #61dafb;

29. }

31. @keyframes App-logo-spin {

32. from {

33. transform: rotate(0deg);

34. }

35. to {

36. transform: rotate(360deg);

37. }

38. margin: 20px;

39. padding: 20px;

40. border: 1px solid navy;

41. }

Ensuite, écrivons le Typeahead.css. J’isole le css spécifique à Typeahead dans un fichier css dédié pour la lisibilité de ce tutoriel, mais ce n’est l’approche que je préfère. Je trouve plus cohérent d’avoir un seul css (scss évidemment) pour l’application, et de mettre les styles qui exceptionnellement doivent être purement locaux directement sur les éléments eux-mêmes sans même passer par du css.

Pour faire des suggestions, nous utiliserons un menu déroulant qui ne perturbe pas le reste du layout de la page, le plus simple c’est d’en faire une div en position absolue dans une div en position relative. Cela affiche donc toujours les suggestions en dessous de l’input, cachant la partie de la page qui se trouve à cet endroit. Comme un menu déroulant quoi. C’est un peu simpliste, et on aimerait sans doute au minimum ajouter un affichage des suggestions au-dessus de l’input quand ce dernier est « en bas » de l’écran, mais étonnement, cette approche simpliste fonctionne déjà très bien dans la plupart des cas.

Les suggestions en position absolue, avec un joli petit bord en relief :

1. .suggestions {

2. position: absolute;

3. background-color: white;

4. z-index: 2;

5. border: 1px solid #ddd;

6. border-radius: 4px;

7. padding: 4px 0;

8. box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);

9. }

Le z-index 2 ne semble pas avoir d’utilité dans un projet vide comme celui-ci, mais si d’autres absolute sont dans le voisinage (comme ceux générés par le package react-window), il est nécessaire pour que les suggestions soient affichées par-dessus.

Les suggestions individuelles vont être affichées sur un fond en alternance gris et blanc, prévoyons déjà qu’on pourra cliquer dessus (cursor pointer), qu’une suggestion sera sélectionnée (fond bleu) et aidons à voir où se trouve la souris avec un fond vert en hover :

1. .suggestion {

2. cursor: pointer;

3. font-size: 12px;

4. white-space: nowrap;

5. padding: 2px 4px;

6. border-bottom: 1px solid #ddd;

7. }

8. .suggestion:hover:not(.selected) {

9. background-color: #d0ff90;

10. }

11. .suggestion:nth-child(odd):not(.selected):not(:hover) {

12. background-color: #f5f6f9;

13. }

14. .selected {

15. background-color: #90d0ff;

16. }

Contrairement à plusieurs Typeaheads populaires, je ne mets pas automatiquement la suggestion sur laquelle se trouve la souris en mode « sélectionné » . Il faut explicitement cliquer dessus (ou utiliser le clavier). Il y a donc bien deux couleurs distinctes de mise en évidence : bleu pour la suggestion sélectionnée et vert pour celle sur laquelle se trouve la souris (si ce n’est pas celle sélectionnée).

Pourquoi devoir cliquer explicitement et ne pas sélectionner automatiquement la suggestion sous la souris ? Je trouve cette sélection automatique extrêmement gênante : quand on tape suffisamment de caractères pour faire apparaître comme premier de la liste l’élément désiré, si jamais la souris se trouve 2 ou 3 lignes plus bas, faire [TAB] pour sortir du champ et sélectionner le premier élément ne fonctionne pas, il va sélectionner l’élément plus loin dans la liste, dont on ne veut pas, mais qui par hasard se trouve sous la souris.

Notons que la couleur bleue montrant la suggestion sélectionnée ne fonctionne pas encore (puisque nous n’avons pas encore implémenté cette notion), mais cela ne va pas tarder. En attendant, trichons un peu en appliquant systématiquement la classe sur la première suggestion de la liste pour pouvoir juger du résultat, la fin de Typeahead.js devient :

1. return (

2. <div>

3. <input value={inputValue} onChange={onChange} />

4. {suggestions[inputValue]

5. ? suggestions[inputValue].map((s, i) => <div key={i}>{s}</div>)

6. : null}

7. {suggestions[inputValue] ? (

8. <div style={{ position: "relative" }}>

9. <div className="suggestions">

10. {suggestions[inputValue].map((s, i) => (

11. <div

12. className={i === 0 ? "suggestion selected" : "suggestion"}

13. key={i}

14. >

15. {s}

16. </div>

17. ))}

18. </div>

19. </div>

20. ) : null}

21. </div>

22. );

23. };

Et le résultat, avec la souris sur la troisième ligne :

VIII - Todo 3A : on peut choisir une suggestion avec le clavier

- étape 1 : la notion de suggestion sélectionnée doit être implémentée ;

- étape 2 : on doit pouvoir changer la suggestion sélectionnée avec le clavier (pas encore avec la souris, ce sera le todo 3B) ;

- étape 3 : quitter le champ (par [TAB] ou autre) doit utiliser la suggestion sélectionnée, dans un premier temps simplement pour remplir l’input field, dans une étape ultérieure le parent devrait pouvoir être prévenu de la suggestion (et pas seulement du texte affiché, mais l’objet entier). En même temps, on videra la liste des suggestions (vider l’objet suggestions) pour ne plus les afficher et être sûrs que les recherches sont relancées quand l’utilisateur revient au Typeahead (en évitant d’utiliser d’anciens résultats potentiellement obsolètes).

VIII-A - étape 1 : notion de suggestion sélectionnée

Plutôt que d’implémenter un état à part contenant la suggestion sélectionnée, commençons par raffiner nos suggestions par terme de recherche : au lieu d’être un tableau, ce sera un objet contenant la suggestion sélectionnée d’une part et la liste des suggestions d’autre part. Et cette liste contiendra les objets de work complets, pas juste titleAuth. titleAuth sera repris comme texte à afficher (display) :

1. if (result.work.length) {

2. // is this an array?

3. // use the titleAuth as display

4. foundSuggestions = result.work.map(v => v.titleAuth);

5. result.work.forEach(v => {

6. v.display = v.titleAuth;

7. });

8. foundSuggestions = {

9. selected: result.work[0],

10. rows: result.work

11. };

12. } else if (result.work.titleAuth) {

13. // or a single item

14. foundSuggestions = [result.work.titleAuth];

15. result.work.display = result.work.titleAuth;

16. foundSuggestions = {

17. selected: result.work,

18. rows: [result.work]

19. };

20. }

Et l’utilisation dans l’html (en assignant suggestions[inputValue] à sugg pour la lisibilité :

1. const sugg = suggestions[inputValue];

2. return (

3. <div>

4. <input value={inputValue} onChange={onChange} />

5. {suggestions[inputValue] ? (

6. {sugg ? (

7. <div style={{ position: "relative" }}>

8. <div className="suggestions">

9. {suggestions[inputValue].map((s, i) => (

10. {sugg.rows.map((s, i) => (

11. <div

12. className={i === 0 ? "suggestion selected" : "suggestion"}

13. className={

14. s === sugg.selected ? "suggestion selected" : "suggestion"

15. }

16. key={i}

17. >

18. {s}

19. {s.display}

20. </div>

21. ))}

22. </div>

23. </div>

24. ) : null}

25. </div>

26. );

27. };

28. export default Typeahead;

VIII-B - étape 2 : changer la suggestion sélectionnée avec le clavier

Après ce travail préparatoire, en apparence rien n’a changé. Mais maintenant on peut gérer le clavier et changer la suggestion sélectionnée avec up & down. Écoutons le clavier dans l’input :

1. <input value={inputValue} onChange={onChange} />

2. <input value={inputValue} onChange={onChange} onKeyDown={onKeyDown} />

Dans onKeyDown on va gérer down, up, home et end. Home et end sont surtout là titre d’exemple, l’essentiel est de gérer down et up.

1. const onKeyDown = e => {

2. if (sugg) {

3. let selectedIndex = sugg.rows.indexOf(sugg.selected);

4. let newSelectedIndex = selectedIndex;

5. if (e.keyCode === 38 || e.key === "ArrowUp") {

6. newSelectedIndex -= 1;

7. if (newSelectedIndex < 0) {

8. newSelectedIndex = sugg.rows.length - 1;

9. }

10. }

11. if (e.keyCode === 40 || e.key === "ArrowDown") {

12. newSelectedIndex += 1;

13. if (newSelectedIndex > sugg.rows.length - 1) {

14. newSelectedIndex = 0;

15. }

16. }

17. if (e.keyCode === 36 || e.key === "Home") {

18. newSelectedIndex = 0;

19. }

20. if (e.keyCode === 35 || e.key === "End") {

21. newSelectedIndex = sugg.rows.length - 1;

22. }

23. if (newSelectedIndex !== selectedIndex) {

24. setSuggestions(v => {

25. return {

26. ...v,

27. [inputValue]: {

28. rows: sugg.rows,

29. selected: sugg.rows[newSelectedIndex]

30. }

31. };

32. });

33. }

34. }

35. };

Et on peut essayer de se balader dans les suggestions, cela fonctionne !

VIII-C - étape 3 : utiliser la suggestion sélectionnée

Le grand moment est enfin arrivé, on va pouvoir utiliser la suggestion sélectionnée. Simplement en réagissant quand le champ perd le focus :

1. <input value={inputValue} onChange={onChange} onKeyDown={onKeyDown} />

2. <input

3. value={inputValue}

4. onChange={onChange}

5. onKeyDown={onKeyDown}

6. onBlur={onBlur}

7. />

Et :

const onBlur = () => {
if (sugg) {
setInputValue(sugg.selected.display);
}
setSuggestions({});
};

C’est ce onBlur minimal qu’il faudra améliorer : communiquer au parent toute la suggestion sélectionnée, pas seulement utiliser son display, et si aucune suggestion n’est sélectionnée, mais que l’utilisateur a bien entré des caractères de recherche, il faut indiquer que le champ est incorrectement rempli. Notons que rien ne permet d’avoir des suggestions sans qu’il n’y en ait une de sélectionnée, donc il suffit bien de tester if (sugg), il n’est pas nécessaire d’écrire if (sugg && sugg.selected).

IX - Todo 3B : on peut choisir une suggestion avec la souris

Ce n’est pas si simple : cliquer sur une sélection va en premier faire perdre le focus au champ, et l’événement onBlur tel qu’écrit actuellement va faire disparaître les suggestions, du coup l’événement « onClick » sur une suggestion n’existe même pas… Pour lui donner une chance d’exister, il faut enregistrer le fait que la souris se trouve sur une suggestion et que le onBlur ne doit rien faire. Sauvons dans une variable d’instance si la souris est sur une suggestion ou pas. Heu, une variable d’instance dans un composant fonctionnel, quésaco ? Et bien c’est une référence tout simplement… Appelons-la ignoreBlurRef, à la création du composant elle vaut false.

Dans onBlur, on ne fait rien s’il faut ignorer le blur :

const onBlur = () => {
if (ignoreBlurRef.current) {
return;
}

Et dans les div représentant les suggestions, on précise qu’il faut ignorer le blur si la souris est sur la div (onMouseEnter/onMouseLeave) et qu’il faut sélectionner la suggestion sur laquelle on clique (onClick) :

{
s === sugg.selected ? "suggestion selected" : "suggestion"
}
key={i}
onMouseEnter={() => {
ignoreBlurRef.current = true;
}}
onMouseLeave={() => {
ignoreBlurRef.current = false;
}}
onClick={() => {
setInputValue(s.display);
setSuggestions({});
}}
>
{s.display}
</div>

Notons tout d’abord qu’on pourrait être tenté de mettre le code de onMouseEnter et onMouseLeave sur la div qui contient toutes les suggestions au lieu de le répéter suggestion par suggestion. Mais ce serait une erreur : cette div englobante possède un léger bord. Si on désactive le blur sur toute cette div, quand on clique sur ce bord on va faire perdre le focus à l’input (puisqu’on clique en dehors du composant) sans qu’aucun code ne réagisse : le blur serait désactivé. Et comme on ne clique pas vraiment sur une sélection, aucune sélection ne passerait. Ensuite, le composant serait « cassé », les suggestions resteraient affichées alors que le focus se trouverait ailleurs sur l’écran…

Notons un vrai problème : tabuler pour sortir du champ ne fait plus rien si la souris se trouve sur une suggestion. Heureusement, quand on tabule l’événement keyDown se passe avant le blur, du coup, pour corriger cela, il suffit de remettre ignoreBlurRef à false dans onKeyDown si on vient de tabuler :

const onKeyDown = e => {
if (e.keyCode === 9 || e.key === "Tab") {
ignoreBlurRef.current = false;
return;
}
if (sugg) {

Et tout fonctionne ! On peut taper « John » et aller cliquer sur l’une des suggestions ou bien laisser la souris sur une suggestion (sans cliquer) et en choisir une autre avec le clavier (down/up/tab). Et bien sûr le clavier fonctionne également si la souris est complètement ailleurs sur l’écran.

X - Todo 4 : le composant doit être indépendant de l’API

Ceci n’est pas la partie la plus intéressante de ce tutoriel dans la découverte de React (d’après moi du moins), mais elle est nécessaire bien sûr pour arriver à un vrai composant réutilisable. Jusqu’à présent, nous n’avons pas vraiment un Typeahead mais plutôt un « Penguin » Typeahead.

Il y a trois éléments du code qui dépendent de l’API utilisée :

1. Comment construire l’URL ;

2. Comment retrouver la liste de suggestions dans le résultat ;

3. Comment construire le « display » d’une suggestion.

Acceptons donc 3 fonctions dans les propriétés pour faire cela :

1. search2url ;

2. result2suggestions ;

3. suggestion2display.

const Typeahead = ({ search2url, result2suggestions, suggestion2display }) => {

Construire l’URL en utilisant la fonction correspondante devient donc simplement :

let query = "/resources/works?search=" + encodeURIComponent(value);
let query = search2url(value);

App.js passant la fonction nécessaire :

<Typeahead
search2url={v => "/resources/works?search=" + encodeURIComponent(v)}

De même, construire les lignes devient :

La fonction étant définie dans le composant appelant (App.js). Pour la lisibilité (et parce que le code est nécessaire deux fois pour les deux Typeahead de l’exemple) on définit la constante result2Suggestions :

Et on l’utilise dans les deux Typeahead:

Finalement, attacher le display est :

Avec App.js qui nous passe la façon désirée d’obtenir le display dans suggestion2display :

Et tout cela fonctionne très bien…

La recherche complète dans Typeahead, en ayant enlevé les console.log qui étaient là pour comprendre le mécanisme au début, devient très lisible :

XI - Todo 5 : quitter le champ sans faire de choix est une erreur

L’endroit où l’on sait si l’on a fait un choix ou pas est ce code dans onBlur :

Il n’y a pas de else à cette condition. Or, si l’utilisateur a entré quelque chose et que cela n’a pas donné de choix possible, c’est une erreur. Sauf bien sûr si c’est un Typeahead optionnel où un texte libre est valable. Nous faisons l’hypothèse que non (on peut le paramétrer si c’est utile).

Mettons le champ en erreur si on quitte sans suggestion sélectionnée alors que l’input n’est pas vide :

Il nous faut un state error donc :

Et il faut un retour visuel, mettons par exemple le bord en rouge :

Sur l’input :

Et cela fonctionne :

Mais pas très bien : une fois le champ mis en erreur, il est impossible de le remettre en l’état correct…

Deux stratégies sont possibles : enlever l’erreur dès que le champ a de nouveau le focus ou au moment où un vrai choix d’une suggestion se fait. Je préfère la première, j’ai toujours trouvé « agressifs » les champs qui me disent « incorrect email » alors que je suis en train de l’encoder. L’autre stratégie est possible bien sûr.

Implémentons le onFocus :

Notons que l’optimisation if(error) setError(false) à la place d’un appel direct setError(false) sans tester est inutile, vu que React le fait déjà dans le code du setter de useState (basé sur Object.is, voir https://reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update)

Il faut lier le onFocus à l’input :

Après avoir mis le champ en erreur, on peut y revenir et le rouge disparaît bien :

Il reste un souci… Supposons que l’on choisisse vraiment un livre :

Puis que l’on passe rapidement dans le champ sans rien changer. Comme ceci par exemple, depuis le deuxième champ qui a le focus, en faisant [Shift]+[Tab] suivi immédiatement de [Tab] :

On obtient l’état erreur :

Puisque le fetch n’a pas eu le temps de rapporter la suggestion de ce livre (peut-être même que le nom complet du livre ne fonctionnerait pas pour le retrouver, ou pas de manière unique).

Il faut donc être plus précis et mettre le champ en erreur quand on le quitte seulement si l’on a au moins modifié quelque chose depuis le dernier choix d’un livre.

Mémorisons si on a changé quelque chose ou pas :

La fonction onChange va enregistrer qu’on est plus dans un état propre :

Les deux endroits où l’on choisit une suggestion remettent l’état vierge :

Dans onBlur :

Et quand on clique sur une suggestion :

Finalement, on ne se met en erreur que s’il y a du texte ET que celui-ci a « changé » :

Notons que l’état « changed » pourrait sans doute aussi s’appeler « isDirty ».

Essayons à nouveau de choisir un livre, puis de rapidement passer par le champ sans le modifier : tout va bien l’état n’est plus erronément mis en erreur.

XII - La touche finale

Du moins pour ce tutoriel, on pourrait encore faire bien des choses. Identifions deux problèmes encore à régler :

1. Tapez un texte de recherche dans le champ et quittez-le sans attendre le retour des suggestions. Le champ est mis en erreur (ce qui est attendu), mais au moment où les suggestions arrivent elles sont affichées alors que le champ n’a plus le focus et cela c’est mauvais.

S’il n’y avait que ce problème, on serait tenté de le régler simplement en testant sur le focus pour afficher ou pas les suggestions. Mais il y en a un autre plus profond :

1. Quand l’API revient, on modifie l’état alors que le composant n’est peut-être plus dans l’arbre. React n’aime pas cela et va nous le faire savoir.

Essayons d’abord de mettre en évidence ce dernier souci. Ajoutons un bouton à App.js qui cache après deux secondes les Typeahead quand on clique dessus.

L’état dans App pour savoir si on cache ou montre :

Le « bouton » pour changer le show après deux secondes :

Ce qui donne :

Et on emballe tout le reste de l’App dans une condition sur l’état show :

Pour mettre en évidence le problème : cliquez sur le bouton et avant que les deux secondes ne se soient écoulées, allez taper du texte dans le Typeahead.

Au moment où le Typeahead est retiré de l’arbre, on a l’erreur suivante dans la console javascript:

D’accord, ce n’est qu’un warning, mais nous ne voulons tout de même pas publier nos applications dans cet état, n’est-ce pas ?

La solution à ces deux soucis est la même : il faut annuler les fetch en cours. Ou bien tester avant d’utiliser leur résultat qu’il est encore approprié de le faire. Néanmoins, stopper les fetch inutiles est plus complet, correct et élégant. Il faut le faire que ce soit parce que le champ perd le focus ou parce que le composant a été retiré de l’arbre. Cela se fait avec un signal d’un AbortController (https://developer.mozilla.org/en-US/docs/Web/API/AbortController).

Dans le cas d’un fetch placé dans un useEffect, il est classique que l’AbortController soit unique à chaque appel du useEffect. De cette façon, React peut tuer le fetch en cours avant de relancer le prochain useEffect. Ici, la perspective est différente, on veut juste un signal « global » qui va tuer toutes les recherches en cours quand il est utilisé. Déclarons-le :

Utilisons-le dans les fetch :

Précisons dans le try/catch du fetch qu’une erreur « Abort » est normale et pas une vraie erreur :

Et finalement, envoyons le signal quand le composant est démonté. On va utiliser un useEffect vide, sans dépendance, la seule chose qui nous intéresse c’est que React va appeler la fonction de nettoyage à la fin de la vie du composant :

Ceci mis en place, répétons le scénario : pousser sur le bouton qui cache le Typeahead après 2 secondes puis rapidement taper les lettres « john », que dit la console :

C’est exactement le comportement attendu, en tout cas quand les requêtes ne sont pas terminées au moment où le composant est démonté. Une API extrêmement rapide n’aurait peut-être pas permis de mettre exactement ce scénario en évidence. Ou alors les premiers appels auraient été terminés et seuls les derniers auraient été annulés. En tout cas, toutes les requêtes encore en cours quand le composant est démonté sont bien annulées ce qui est le but.

Il ne reste qu’à annuler les requêtes en cours quand on perd le focus également et à recréer du coup un AbortController (une alternative possible, non montrée dans ce tutoriel, serait de créer l’AbortController seulement si nécessaire, par exemple au moment de lancer un fetch s’il n’y a pas d’AbortController courant on en crée un).

L’unique ajout se trouve au début de onBlur qui devient :

On annule les requêtes en cours même si ignoreBlur est vrai, puisqu’un blur event avec ignoreBlur vrai veut dire qu’on est train de cliquer sur une sélection et que tout fetch non encore terminé est inutile.

Réessayons le scénario consistant à taper du texte dans le champ et à le quitter sans attendre que les suggestions arrivent :

Le champ est en erreur, ce qui normal, et la console dit :

Les requêtes a et ab avaient donc eu le temps de se terminer, mais pas abc et abcd qui ont donc été annulées quand on est sorti du champ.

XIII - Bonus

On va mettre en gras dans le display le texte de recherche pour mettre en évidence pourquoi cette suggestion a été renvoyée par l’API.

Comme React accepte directement un tableau d’éléments en JSX, écrivons une petite fonction qui décompose un display en un tableau de <span> et <b>.

<b> pour entourer toute partie du titre qui correspond au texte de recherche et <span> pour les autres parties.

Si on recherche les caractères « les », le titre « Les misérables » devient :

<b>Les</b>, <span> misérab</span>, <b>les</b>

Quand on prépare le display dans onChange, on va préparer ausssi le HTML :

Et simplement afficher le HTML au lieu du display dans les suggestions :

Le résultat avec « Pe » mis en évidence, y compris quand il y en a plusieurs :

Notez que, dans cet exemple, « Pe » est chaque fois en début de mot, mais cela n’a pas d’importance, les caractères seraient mis en gras en milieu de mot également. Comme tout cela est du HTML, au lieu de simplement mettre en gras, vous pouvez laisser libre cours à votre imagination.

XIV - Pour le lecteur attentif

L’abortController pour annuler les requêtes en cours quand le composant perd le focus ou est démonté est correct.

Néanmoins, il ne résout pas tous les cas de pertes de focus ou de démontage.

Pouvez-vous voir pourquoi avant de lire plus loin ? Prenez deux minutes pour essayer de trouver (si cela ne vous a pas déjà frappé à la lecture des paragraphes précédents).

Le souci c’est que l’abortCOntroller gère les cas où la perte de focus survient pendant l’await du fecth, mais nous avons un deuxième await … Celui qui lit le retour http pour convertir sa représentation json en un objet javascript :

Le contenu des requêtes http ramenant les quelques lignes pour le Typeahead devrait être très court et par conséquent l’analyse json devrait être instantanée ou presque. Donc la mise en évidence d’un problème possible à cet endroit (quitter le champ exactement au moment où ce code asynchrone s’exécute) est difficile pour ne pas dire impossible. Il n’en reste pas moins que c’est potentiellement possible vu le caractère asynchrone de l’analyse json, et que ce ne serait vraiment pas propre de laisser ce risque potentiel dans le code.

On doit donc quand même se soucier de savoir si on a toujours le focus ou pas à la fin de l’exécution du code asynchrone. Pour ce faire, on va maintenir une référence sur le fait d’avoir le focus ou pas :

Mettre cette référence à faux quand on perd le focus :

Quand le composant est démonté :

Cela correspond assez logiquement aux deux endroits où l’on utilise l’abortController.

Remettre le focus dans onFocus :

Et finalement, tester après le code asynchrone si l’on a toujours le focus avant de continuer :

Notons qu’avec l’ajout de ce code, l’abortController n’est plus strictement nécessaire. Cela reste néanmoins préférable de ne pas laisser tourner des requêtes fetch inutiles. Notons aussi une subtile différence si l’on supprime l’abortController avec lescénario suivant :

1. L’utilisateur donne le focus au champ ;

2. Il tape le caractère « a » ;

3. La requête démarre ;

4. L’utilisateur quitte le champ alors que la requête s’exécute ;

5. Il revient dans le champ avant que la requête ne soit terminée.

Avec un AbortController, la requête est tuée à l’étape 4.

Avec seulement le test sur le focus, comme le champ a de nouveau le focus quand la requête se termine, son résultat sera exploité. Cela ouvre une question laissée à titre d’exercice au lecteur : dans la version qui utilise AbortController, ne faudrait-il pas relancer la requête quand le champ reçoit le focus, avant d’attendre l’événement onChange ?

Fin de ce tutoriel, en espérant qu’il ait pu vous être d’une utilité même minime, merci pour votre attention.

XV - Remerciements Developpez.com

Nous tenons à remercier Malick pour la mise au gabarit et escartefigue pour la relecture orthographique.