2019-12-03 22:00:21 +00:00
|
|
|
|
import React, {useState, useRef} from 'react';
|
2019-12-04 04:35:27 +00:00
|
|
|
|
import * as diacritics from 'diacritics';
|
2019-12-03 22:00:21 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
});
|
|
|
|
|
|
2019-12-04 18:39:57 +00:00
|
|
|
|
/**
|
|
|
|
|
* Met le nom d’un 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);
|
|
|
|
|
|
2019-12-04 04:35:27 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2019-12-04 18:39:57 +00:00
|
|
|
|
const termMatchesPrefix = (prefix, {name, alias}) =>
|
2019-12-04 04:35:27 +00:00
|
|
|
|
{
|
2019-12-04 18:39:57 +00:00
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
);
|
2019-12-04 04:35:27 +00:00
|
|
|
|
};
|
|
|
|
|
|
2019-12-03 22:00:21 +00:00
|
|
|
|
/**
|
|
|
|
|
* Zone de saisie des termes de recherche.
|
|
|
|
|
*
|
|
|
|
|
* @prop terms Ensemble de termes déjà validés.
|
|
|
|
|
* @param availableTerms Termes pouvant être ajoutés par l’utilisateur.
|
|
|
|
|
* @prop setTerms Fonction de rappel utilisée pour modifier l’ensemble des
|
|
|
|
|
* termes validés par l’utilisateur.
|
|
|
|
|
*/
|
|
|
|
|
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 à l’ensemble des termes sélectionnés.
|
|
|
|
|
*
|
|
|
|
|
* @param term Terme à ajouter.
|
|
|
|
|
*/
|
|
|
|
|
const addTerm = term =>
|
|
|
|
|
{
|
|
|
|
|
if (terms.some(({id}) => id === term.id))
|
|
|
|
|
{
|
|
|
|
|
// N’ajoute 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 l’appui sur une touche dans le champ de saisie.
|
|
|
|
|
*
|
|
|
|
|
* @param ev Informations sur l’événement d’appui.
|
|
|
|
|
*/
|
|
|
|
|
const handleInputKeyDown = ev =>
|
|
|
|
|
{
|
|
|
|
|
if (ev.keyCode === keys.enter && value)
|
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
// Touche Entrée : ajout de la suggestion ayant le focus
|
2019-12-03 22:00:21 +00:00
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (focusedSuggestion < suggestions.length)
|
|
|
|
|
{
|
|
|
|
|
addTerm(suggestions[focusedSuggestion]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (
|
|
|
|
|
ev.keyCode === keys.backspace
|
|
|
|
|
&& !value
|
|
|
|
|
&& terms.length !== 0
|
|
|
|
|
)
|
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
// Touche Retour alors que le champ de saisie est vide :
|
2019-12-03 22:00:21 +00:00
|
|
|
|
// retrait du dernier terme
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
setTerms(terms.slice(0, -1));
|
|
|
|
|
}
|
|
|
|
|
else if (ev.keyCode === keys.up && focusedSuggestion > 0)
|
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
// Touche Haut : focus de la suggestion précédente
|
2019-12-03 22:00:21 +00:00
|
|
|
|
ev.preventDefault();
|
|
|
|
|
setFocusedSuggestion(focusedSuggestion - 1);
|
|
|
|
|
}
|
|
|
|
|
else if (
|
|
|
|
|
ev.keyCode === keys.down
|
|
|
|
|
&& focusedSuggestion < suggestions.length - 1
|
|
|
|
|
)
|
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
// Touche Bas : focus de la suggestion suivante
|
2019-12-03 22:00:21 +00:00
|
|
|
|
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 n’ont pas déjà été choisis
|
|
|
|
|
.filter(({id: suggestionId}) =>
|
|
|
|
|
!terms.some(({id: termId}) =>
|
|
|
|
|
termId === suggestionId
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
// Filtre ceux dont le nom correspond à la saisie
|
2019-12-04 04:35:27 +00:00
|
|
|
|
.filter(termMatchesPrefix.bind(null, nextValue))
|
2019-12-03 22:00:21 +00:00
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2019-12-04 04:20:06 +00:00
|
|
|
|
};
|
2019-12-03 22:00:21 +00:00
|
|
|
|
|
|
|
|
|
export default TermInput;
|