Initial commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/**
|
||||
* CategorySelect — multi-select with checkboxes + inline "Add category"
|
||||
*
|
||||
* Props:
|
||||
* selected : number[] — ids sélectionnés
|
||||
* onChange : (ids: number[]) => void
|
||||
* categories : { id, nom }[] — liste complète fournie par le parent
|
||||
* onCategoryAdded : ({ id, nom }) => void — appelé après création inline
|
||||
*
|
||||
* Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect)
|
||||
* pour échapper au overflow:auto des modales parentes.
|
||||
*/
|
||||
export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
/* ── Position fixe calculée à chaque ouverture ───────────────── */
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropPos({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
/* ── Fermeture : clic extérieur + scroll + resize ────────────── */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
if (wrapRef.current?.contains(e.target)) return;
|
||||
// Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap)
|
||||
const drop = document.getElementById('cat-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnScroll = (e) => {
|
||||
const drop = document.getElementById('cat-select-dropdown-portal');
|
||||
if (drop?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', close);
|
||||
window.addEventListener('scroll', closeOnScroll, true);
|
||||
window.addEventListener('resize', closeOnScroll);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('scroll', closeOnScroll, true);
|
||||
window.removeEventListener('resize', closeOnScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id) => {
|
||||
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
};
|
||||
|
||||
const addCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const cat = await api.post('/categories', { nom: newName.trim() });
|
||||
onCategoryAdded(cat);
|
||||
onChange([...selected, cat.id]);
|
||||
setNewName('');
|
||||
setAdding(false);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Label du bouton déclencheur ─────────────────────────────── */
|
||||
const label = (() => {
|
||||
if (selected.length === 0) return "Aucune catégorie d'investissement";
|
||||
const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom);
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return `${names.length} catégories d'invest.`;
|
||||
})();
|
||||
|
||||
/* ── Dropdown rendu en position:fixed ────────────────────────── */
|
||||
const dropdown = open ? (
|
||||
<div
|
||||
id="cat-select-dropdown-portal"
|
||||
className="cat-select-dropdown"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropPos.top,
|
||||
left: dropPos.left,
|
||||
width: dropPos.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{/* Liste des catégories */}
|
||||
{categories.length === 0 && (
|
||||
<div className="cat-select-empty">Aucune catégorie d'investissement disponible</div>
|
||||
)}
|
||||
{categories.map(cat => {
|
||||
const checked = selected.includes(cat.id);
|
||||
return (
|
||||
<label key={cat.id} className={`cat-select-item${checked ? ' checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(cat.id)}
|
||||
/>
|
||||
<span>{cat.nom}</span>
|
||||
{checked && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Séparateur + ajout */}
|
||||
<div className="cat-select-sep" />
|
||||
|
||||
{!adding ? (
|
||||
<button type="button" className="cat-select-add-btn"
|
||||
onClick={() => { setAdding(true); setErr(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Ajouter une catégorie d'investissement
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={addCategory} className="cat-select-new-form">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Nom de la catégorie d'investissement"
|
||||
maxLength={100}
|
||||
/>
|
||||
<div className="cat-select-new-actions">
|
||||
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
|
||||
{busy ? '…' : 'Créer'}
|
||||
</button>
|
||||
<button type="button" className="ghost"
|
||||
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="cat-select-err">{err}</div>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={wrapRef} className="cat-select-wrap">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`cat-select-trigger${open ? ' open' : ''}`}
|
||||
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="cat-select-label">{label}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
|
||||
aria-hidden="true">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */}
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user