wikimedica-disease-search/app/src/components/TermInput.js

235 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {useState, useRef} from 'react';
import * as diacritics from 'diacritics';
/**
* Codes des touches du clavier par nom.
*/
const keys = Object.assign(Object.create(null), {
'backspace': 8,
'enter': 13,
'left': 37,
'up': 38,
'right': 39,
'down': 40,
});
/**
* Met le nom dun terme sous forme normalisée pour la recherche approximative.
*
* Dans la forme normalisée, tous les accents sont ôtés et la casse est réduite
* en minuscules.
*
* @param name Nom à normaliser.
* @return Liste des mots du nom de terme normalisé.
*/
const normalizeName = name =>
diacritics.remove(name.toLowerCase())
.split(/\s+/g);
/**
* Vérifie si un terme est similaire à un préfixe saisi.
*
* @param prefix Préfixe saisi.
* @param term Terme à vérifier.
* @return Vrai si le terme est considéré comme similaire au préfixe.
*/
const termMatchesPrefix = (prefix, {name, alias}) =>
{
const haystack = [name].concat(alias)
.map(normalizeName)
.reduce((prev, next) => prev.concat(next), []);
const needle = normalizeName(prefix);
return needle.every(item =>
haystack.some(word =>
word.startsWith(item)
)
);
};
/**
* Zone de saisie des termes de recherche.
*
* @prop terms Ensemble de termes déjà validés.
* @param availableTerms Termes pouvant être ajoutés par lutilisateur.
* @prop setTerms Fonction de rappel utilisée pour modifier lensemble des
* termes validés par lutilisateur.
*/
const TermInput = ({terms, availableTerms, setTerms}) =>
{
// Valeur actuellement saisie dans le champ de recherche de termes
const [value, setValue] = useState('');
// Liste des termes suggérés par autocomplétion
const [suggestions, setSuggestions] = useState([]);
// Terme ayant le focus parmi les suggestions
const [focusedSuggestion, setFocusedSuggestion] = useState(0);
// Référence au champ de saisie
const inputRef = useRef(null);
/**
* Ajoute un terme à lensemble des termes sélectionnés.
*
* @param term Terme à ajouter.
*/
const addTerm = term =>
{
if (terms.some(({id}) => id === term.id))
{
// Najoute pas les termes déjà sélectionnés
return;
}
setTerms(terms.concat([term]));
setValue('');
setSuggestions([]);
setFocusedSuggestion(0);
if (inputRef.current !== null)
{
inputRef.current.focus();
}
};
/**
* Supprime un terme des termes sélectionnés.
*
* @param index Indice du terme à supprimer dans la liste.
*/
const removeTerm = index =>
{
setTerms([
...terms.slice(0, index),
...terms.slice(index + 1)
]);
};
/**
* Gère lappui sur une touche dans le champ de saisie.
*
* @param ev Informations sur lévénement dappui.
*/
const handleInputKeyDown = ev =>
{
if (ev.keyCode === keys.enter && value)
{
// Touche Entrée : ajout de la suggestion ayant le focus
ev.preventDefault();
if (focusedSuggestion < suggestions.length)
{
addTerm(suggestions[focusedSuggestion]);
}
}
else if (
ev.keyCode === keys.backspace
&& !value
&& terms.length !== 0
)
{
// Touche Retour alors que le champ de saisie est vide :
// retrait du dernier terme
ev.preventDefault();
setTerms(terms.slice(0, -1));
}
else if (ev.keyCode === keys.up && focusedSuggestion > 0)
{
// Touche Haut : focus de la suggestion précédente
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion - 1);
}
else if (
ev.keyCode === keys.down
&& focusedSuggestion < suggestions.length - 1
)
{
// Touche Bas : focus de la suggestion suivante
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion + 1);
}
};
/**
* Gère la modification du contenu du champ de saisie.
*
* @param ev Informations sur lévénement de modification.
*/
const handleInputChange = ev =>
{
const nextValue = ev.target.value;
setValue(nextValue);
if (nextValue.length === 0)
{
// On ne fait pas de suggestion si la valeur saisie est trop courte
setSuggestions([]);
}
else
{
setSuggestions(availableTerms
// Sélectionne les termes qui nont pas déjà été choisis
.filter(({id: suggestionId}) =>
!terms.some(({id: termId}) =>
termId === suggestionId
)
)
// Filtre ceux dont le nom correspond à la saisie
.filter(termMatchesPrefix.bind(null, nextValue))
);
}
};
return (
<div className="TermInput">
{terms.map((term, index) =>
<span
key={term.id}
className="TermInput_term"
onClick={removeTerm.bind(null, index)}
>
{term.name}
</span>
)}
<input
autoFocus={true}
type="text" className="TermInput_input"
placeholder="Rechercher un symptôme, un signe ou une maladie…"
value={value} ref={inputRef}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown} />
<ul className="TermInput_suggestions">
{suggestions.map((suggestion, index) =>
<li
key={suggestion.id}
className={[
'TermInput_suggestion',
index === focusedSuggestion
? 'TermInput_suggestion-focus'
: ''
].join(' ')}
onMouseEnter={setFocusedSuggestion.bind(null, index)}
onClick={addTerm.bind(null, suggestion)}
>
{suggestion.name}
{suggestion.alias.length >= 1 ? (
<span
className="TermInput_suggestionAlias"
title={`Alias du terme: ${suggestion.alias.join(', ')}`}
>
{suggestion.alias.join(', ')}
</span>
) : null}
</li>
)}
</ul>
</div>
);
};
export default TermInput;