184 lines
6.6 KiB
JavaScript
184 lines
6.6 KiB
JavaScript
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>
|
||
);
|
||
}
|