fixed code
This commit is contained in:
183
src/components/CityFilter.jsx
Normal file
183
src/components/CityFilter.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* CityFilter — мультиселект городов с поиском и свободным вводом.
|
||||
*
|
||||
* @param {string[]} cities — доступные города (из /posts/cities)
|
||||
* @param {string[]} value — выбранные города
|
||||
* @param {(next: string[]) => void} onChange — изменение выбора
|
||||
*/
|
||||
export default function CityFilter({ cities, value, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Закрываем дропдаун при клике вне компонента
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, [open]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
// Города, совпадающие с введённым текстом (выбранные помечаются галочкой)
|
||||
const options = useMemo(() => {
|
||||
const q = normalizedQuery;
|
||||
return cities.filter(c => c && (!q || c.toLowerCase().includes(q)));
|
||||
}, [cities, normalizedQuery]);
|
||||
|
||||
// Свободный ввод доступен, если введён текст, которого нет ни в списке, ни в выбранных
|
||||
const canAddCustom = normalizedQuery.length > 0 &&
|
||||
!value.some(c => c.toLowerCase() === normalizedQuery) &&
|
||||
!cities.some(c => c.toLowerCase() === normalizedQuery);
|
||||
|
||||
const toggle = (city) => {
|
||||
if (value.includes(city)) {
|
||||
onChange(value.filter(c => c !== city));
|
||||
} else {
|
||||
onChange([...value, city]);
|
||||
}
|
||||
setQuery("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const addCustom = () => {
|
||||
const city = query.trim();
|
||||
if (city && !value.includes(city)) {
|
||||
onChange([...value, city]);
|
||||
}
|
||||
setQuery("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (canAddCustom) {
|
||||
addCustom();
|
||||
} else if (options.length) {
|
||||
toggle(options[0]);
|
||||
}
|
||||
} else if (e.key === "Backspace" && query === "" && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="f-city-multiselect" ref={rootRef}>
|
||||
<div className={`f-city-ms-control${open ? " open" : ""}`}
|
||||
onClick={() => setOpen(v => !v)}>
|
||||
<svg className="f-city-ms-icon" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||
strokeLinejoin="round">
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
<path d="M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11z"/>
|
||||
</svg>
|
||||
|
||||
<div className="f-city-ms-tags">
|
||||
{value.map(city => (
|
||||
<span key={city} className="f-city-ms-tag">
|
||||
{city}
|
||||
<button
|
||||
type="button"
|
||||
className="f-city-ms-tag-remove"
|
||||
aria-label={t('feed.filterCitiesRemove')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(city);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.5" strokeLinecap="round" width="10"
|
||||
height="10">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="f-city-ms-input"
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={value.length ? "" : t('feed.filterCitiesPlaceholder')}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="f-city-ms-clear"
|
||||
aria-label={t('feed.filterCitiesClear')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange([]);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
width="16" height="16">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<svg className="f-city-ms-chevron" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||
strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="f-city-ms-dropdown">
|
||||
{options.length === 0 && !canAddCustom && (
|
||||
<div className="f-city-ms-empty">{t('feed.filterCitiesEmpty')}</div>
|
||||
)}
|
||||
|
||||
{canAddCustom && (
|
||||
<button type="button" className="f-city-ms-option f-city-ms-add"
|
||||
onClick={() => {
|
||||
addCustom();
|
||||
setOpen(false);
|
||||
}}>
|
||||
<span className="f-city-ms-check">+</span>
|
||||
<span className="f-city-ms-label">
|
||||
{t('feed.filterCitiesAdd', { city: query.trim() })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{options.map(city => {
|
||||
const selected = value.includes(city);
|
||||
return (
|
||||
<button key={city} type="button"
|
||||
className={`f-city-ms-option${selected ? " selected" : ""}`}
|
||||
onClick={() => toggle(city)}>
|
||||
<span className="f-city-ms-check">{selected ? "✓" : ""}</span>
|
||||
<span className="f-city-ms-label">{city}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user