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

235 lines
6.9 KiB
JavaScript
Raw Normal View History

import React, {useState, useRef} from 'react';
2019-12-04 04:35:27 +00:00
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);
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.
*/
const termMatchesPrefix = (prefix, {name, alias}) =>
2019-12-04 04:35:27 +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
};
/**
* 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)
{
2019-12-04 04:20:06 +00:00
// 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
)
{
2019-12-04 04:20:06 +00:00
// 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)
{
2019-12-04 04:20:06 +00:00
// Touche Haut : focus de la suggestion précédente
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
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
2019-12-04 04:35:27 +00:00
.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}
2019-12-04 18:53:33 +00:00
{suggestion.alias.length >= 1 ? (
<span
className="TermInput_suggestionAlias"
title={`Alias du terme: ${suggestion.alias.join(', ')}`}
>
{suggestion.alias.join(', ')}
</span>
) : null}
</li>
)}
</ul>
</div>
);
2019-12-04 04:20:06 +00:00
};
export default TermInput;