Files
nahodka-front/src/components/CityFilter.jsx
SlimusMinus 7f59ee1f38 fixed code
2026-08-11 00:24:07 +03:00

184 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}