first commit
This commit is contained in:
168
src/components/filters/FilterDropdown.tsx
Normal file
168
src/components/filters/FilterDropdown.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface FilterDropdownProps {
|
||||
title?: string; // Делаем необязательным
|
||||
options: string[];
|
||||
multi?: boolean;
|
||||
showAll?: boolean;
|
||||
defaultOpen?: boolean; // Открыт ли по умолчанию
|
||||
hasMore?: boolean; // Есть ли еще опции для загрузки
|
||||
onShowMore?: () => void; // Обработчик "Показать еще"
|
||||
isMobile?: boolean; // Добавляем флаг для мобильной версии
|
||||
selectedValues?: string[]; // Выбранные значения
|
||||
onChange?: (values: string[]) => void; // Обработчик изменений
|
||||
}
|
||||
|
||||
const FilterDropdown: React.FC<FilterDropdownProps> = ({
|
||||
title,
|
||||
options,
|
||||
multi = true,
|
||||
showAll = false,
|
||||
defaultOpen = false,
|
||||
hasMore = false,
|
||||
onShowMore,
|
||||
isMobile = false,
|
||||
selectedValues = [],
|
||||
onChange
|
||||
}) => {
|
||||
const [open, setOpen] = useState(isMobile || defaultOpen); // На мобилке или если defaultOpen - сразу открыт
|
||||
const [showAllOptions, setShowAllOptions] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>(selectedValues);
|
||||
const visibleOptions = showAll && !showAllOptions ? options.slice(0, 4) : options;
|
||||
|
||||
useEffect(() => {
|
||||
// Сравниваем содержимое массивов, а не ссылки
|
||||
if (JSON.stringify(selected) !== JSON.stringify(selectedValues)) {
|
||||
setSelected(selectedValues);
|
||||
}
|
||||
}, [selectedValues, selected]);
|
||||
|
||||
const handleSelect = (option: string) => {
|
||||
let newSelected: string[];
|
||||
if (multi) {
|
||||
newSelected = selected.includes(option)
|
||||
? selected.filter(o => o !== option)
|
||||
: [...selected, option];
|
||||
} else {
|
||||
newSelected = selected.includes(option) ? [] : [option];
|
||||
}
|
||||
|
||||
setSelected(newSelected);
|
||||
|
||||
// Вызываем колбэк только если значения действительно изменились
|
||||
if (onChange && JSON.stringify(newSelected) !== JSON.stringify(selected)) {
|
||||
onChange(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// Мобильная версия - всегда открытый список
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="filter-block-mobile">
|
||||
<div className="dropdown w-dropdown w--open">
|
||||
<div className="dropdown-toggle w-dropdown-toggle" style={{ cursor: 'default', background: 'none', boxShadow: 'none' }}>
|
||||
<h4 className="heading-2">{title}</h4>
|
||||
</div>
|
||||
<nav className="dropdown-list w-dropdown-list" style={{ display: 'block', position: 'static', boxShadow: 'none', background: 'transparent', padding: 0 }}>
|
||||
<div className="w-layout-vflex flex-block-17">
|
||||
{visibleOptions.map(option => (
|
||||
<div className="div-block-8" key={option} onClick={() => handleSelect(option)}>
|
||||
<div className={`div-block-7${selected.includes(option) ? " active" : ""}`}>
|
||||
{selected.includes(option) && (
|
||||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none">
|
||||
<path d="M5.33333 12L0 6.89362L1.86667 5.10638L5.33333 8.42553L14.1333 0L16 1.78723L5.33333 12Z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-block-12">{option}</div>
|
||||
</div>
|
||||
))}
|
||||
{((showAll && options.length > 4) || hasMore) && (
|
||||
<div className="w-layout-vflex flex-block-17">
|
||||
<div
|
||||
className="div-block-8"
|
||||
onClick={() => {
|
||||
if (hasMore && onShowMore) {
|
||||
onShowMore();
|
||||
} else {
|
||||
setShowAllOptions(!showAllOptions);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="text-block-13">
|
||||
{hasMore ? "Показать еще" : (showAllOptions ? "Скрыть" : "Показать все")}
|
||||
</div>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/images/arrow_drop_down.svg"
|
||||
alt=""
|
||||
style={{ marginLeft: 4, transform: showAllOptions ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Десктопная версия - классический dropdown
|
||||
return (
|
||||
<div className={`dropdown w-dropdown${open ? " w--open" : ""}`}>
|
||||
<div className="dropdown-toggle w-dropdown-toggle" onClick={() => setOpen(!open)}>
|
||||
<h4 className="heading-2">
|
||||
{title} {selectedValues.length > 0 && `(${selectedValues.length})`}
|
||||
</h4>
|
||||
<div className="icon-3 w-icon-dropdown-toggle"></div>
|
||||
</div>
|
||||
{open && (
|
||||
<nav className="dropdown-list w-dropdown-list">
|
||||
<div className="w-layout-vflex flex-block-17">
|
||||
{visibleOptions.map(option => (
|
||||
<div className="div-block-8" key={option} onClick={() => handleSelect(option)}>
|
||||
<div className={`div-block-7${selected.includes(option) ? " active" : ""}`}>
|
||||
{selected.includes(option) && (
|
||||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none">
|
||||
<path d="M5.33333 12L0 6.89362L1.86667 5.10638L5.33333 8.42553L14.1333 0L16 1.78723L5.33333 12Z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-block-12">{option}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{((showAll && options.length > 4) || hasMore) && (
|
||||
<div className="w-layout-vflex flex-block-17">
|
||||
<div
|
||||
className="div-block-8"
|
||||
onClick={() => {
|
||||
if (hasMore && onShowMore) {
|
||||
onShowMore();
|
||||
} else {
|
||||
setShowAllOptions(!showAllOptions);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="text-block-13">
|
||||
{hasMore ? "Показать еще" : (showAllOptions ? "Скрыть" : "Показать все")}
|
||||
</div>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/images/arrow_drop_down.svg"
|
||||
alt=""
|
||||
style={{ marginLeft: 4, transform: showAllOptions ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDropdown;
|
279
src/components/filters/FilterRange.tsx
Normal file
279
src/components/filters/FilterRange.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
|
||||
|
||||
interface FilterRangeProps {
|
||||
title: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
isMobile?: boolean; // Добавляем флаг для мобильной версии
|
||||
value?: [number, number] | null; // Текущее значение диапазона
|
||||
onChange?: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN = 1;
|
||||
const DEFAULT_MAX = 32000;
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));
|
||||
|
||||
const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max = DEFAULT_MAX, isMobile = false, value = null, onChange }) => {
|
||||
const [from, setFrom] = useState(value ? value[0] : min);
|
||||
const [to, setTo] = useState(value ? value[1] : max);
|
||||
const [dragging, setDragging] = useState<null | "from" | "to">(null);
|
||||
const [trackWidth, setTrackWidth] = useState(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Обновляем локальное состояние при изменении внешнего значения
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setFrom(value[0]);
|
||||
setTo(value[1]);
|
||||
} else {
|
||||
setFrom(min);
|
||||
setTo(max);
|
||||
}
|
||||
}, [value, min, max]);
|
||||
|
||||
// Обновляем ширину полосы при монтировании и ресайзе
|
||||
useLayoutEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (trackRef.current) setTrackWidth(trackRef.current.offsetWidth);
|
||||
};
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}, []);
|
||||
|
||||
// Перевод значения в px и обратно
|
||||
const valueToPx = (value: number) => trackWidth ? ((value - min) / (max - min)) * trackWidth : 0;
|
||||
const pxToValue = (px: number) => trackWidth ? Math.round((px / trackWidth) * (max - min) + min) : min;
|
||||
|
||||
// Drag logic
|
||||
const onMouseDown = (type: "from" | "to") => (e: React.MouseEvent) => {
|
||||
setDragging(type);
|
||||
e.preventDefault();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!trackRef.current) return;
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
let x = e.clientX - rect.left;
|
||||
x = clamp(x, 0, trackWidth);
|
||||
const value = clamp(pxToValue(x), min, max);
|
||||
if (dragging === "from") {
|
||||
setFrom(v => clamp(Math.min(value, to), min, to));
|
||||
} else {
|
||||
setTo(v => clamp(Math.max(value, from), from, max));
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
setDragging(null);
|
||||
if (onChange) {
|
||||
onChange([from, to]);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [dragging, from, to, min, max, trackWidth, onChange]);
|
||||
|
||||
// Input handlers
|
||||
const handleFromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = Number(e.target.value.replace(/\D/g, ""));
|
||||
if (isNaN(v)) v = min;
|
||||
setFrom(clamp(Math.min(v, to), min, to));
|
||||
};
|
||||
const handleToInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = Number(e.target.value.replace(/\D/g, ""));
|
||||
if (isNaN(v)) v = max;
|
||||
setTo(clamp(Math.max(v, from), from, max));
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (onChange) {
|
||||
onChange([from, to]);
|
||||
}
|
||||
};
|
||||
|
||||
// px позиции для точек
|
||||
const pxFrom = valueToPx(from);
|
||||
const pxTo = valueToPx(to);
|
||||
|
||||
// Мобильная версия - без dropdown
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="filter-block-mobile">
|
||||
<div className="dropdown w-dropdown w--open">
|
||||
<div className="dropdown-toggle w-dropdown-toggle" style={{ cursor: 'default', background: 'none', boxShadow: 'none' }}>
|
||||
<h4 className="heading-2">{title}</h4>
|
||||
</div>
|
||||
<nav className="dropdown-list w-dropdown-list" style={{ display: 'block', position: 'static', boxShadow: 'none', background: 'transparent', padding: 0 }}>
|
||||
<div className="form-block-2">
|
||||
<form className="form-2" onSubmit={e => e.preventDefault()} style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<div className="div-block-5" style={{ position: 'relative', flex: 1 }}>
|
||||
<label htmlFor="from" className="field-label" style={{ position: 'absolute', left: 3, top: '50%', transform: 'translateY(-50%)', color: '#888', fontSize: 15, pointerEvents: 'none' }}>от</label>
|
||||
<input
|
||||
className="text-field-2 w-input"
|
||||
maxLength={6}
|
||||
name="from"
|
||||
placeholder={String(min)}
|
||||
type="text"
|
||||
id="from"
|
||||
value={from}
|
||||
onChange={handleFromInput}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="div-block-5" style={{ position: 'relative', flex: 1 }}>
|
||||
<label htmlFor="to" className="field-label" style={{ position: 'absolute', left: 3, top: '50%', transform: 'translateY(-50%)', color: '#888', fontSize: 15, pointerEvents: 'none' }}>до</label>
|
||||
<input
|
||||
className="text-field-2 w-input"
|
||||
maxLength={6}
|
||||
name="to"
|
||||
placeholder={String(max)}
|
||||
type="text"
|
||||
id="to"
|
||||
value={to}
|
||||
onChange={handleToInput}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="div-block-6" style={{ position: 'relative', height: 32, marginTop: 12 }} ref={trackRef}>
|
||||
<div className="track" style={{ position: 'absolute', top: 14, left: 0, right: 0, height: 4, borderRadius: 2 }}></div>
|
||||
<div
|
||||
className="track fill"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 14,
|
||||
left: pxFrom,
|
||||
width: pxTo - pxFrom - 20,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
zIndex: 2,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="start"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: pxFrom ,
|
||||
zIndex: 3,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseDown={onMouseDown("from")}
|
||||
></div>
|
||||
<div
|
||||
className="start end"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: pxTo - 20,
|
||||
zIndex: 3,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseDown={onMouseDown("to")}
|
||||
></div>
|
||||
</div>
|
||||
<div className="range-values" style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 14, color: '#888' }}>
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Десктопная версия - с dropdown
|
||||
return (
|
||||
<div className={`dropdown w-dropdown${open ? " w--open" : ""}`}>
|
||||
<div className="dropdown-toggle w-dropdown-toggle" onClick={() => setOpen(o => !o)} tabIndex={0} aria-expanded={open} aria-label={`Фильтр диапазона ${title}`}>
|
||||
<h4 className="heading-2">{title}</h4>
|
||||
<div className="icon-3 w-icon-dropdown-toggle"></div>
|
||||
</div>
|
||||
{open && (
|
||||
<nav className="dropdown-list w-dropdown-list">
|
||||
<div className="form-block-2">
|
||||
<form className="form-2" onSubmit={e => e.preventDefault()}>
|
||||
<div className="div-block-5">
|
||||
<label htmlFor="from" className="field-label">от</label>
|
||||
<input
|
||||
className="text-field-2 w-input"
|
||||
maxLength={6}
|
||||
name="from"
|
||||
placeholder={String(min)}
|
||||
type="text"
|
||||
id="from"
|
||||
value={from}
|
||||
onChange={handleFromInput}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="div-block-5">
|
||||
<label htmlFor="to" className="field-label">до</label>
|
||||
<input
|
||||
className="text-field-2 w-input"
|
||||
maxLength={6}
|
||||
name="to"
|
||||
placeholder={String(max)}
|
||||
type="text"
|
||||
id="to"
|
||||
value={to}
|
||||
onChange={handleToInput}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="div-block-6" style={{ position: "relative", height: 32, marginTop: 12 }} ref={trackRef}>
|
||||
<div className="track" style={{ position: "absolute", top: 14, left: 0, right: 0, height: 4, borderRadius: 2 }}></div>
|
||||
<div
|
||||
className="track fill"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 14,
|
||||
left: pxFrom,
|
||||
width: pxTo - pxFrom,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
zIndex: 2,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="start"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: pxFrom - 8,
|
||||
zIndex: 3,
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onMouseDown={onMouseDown("from")}
|
||||
></div>
|
||||
<div
|
||||
className="start end"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: pxTo - 8,
|
||||
zIndex: 3,
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onMouseDown={onMouseDown("to")}
|
||||
></div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterRange;
|
Reference in New Issue
Block a user