Files
router-lists-ui/frontend/src/components/IconPicker.jsx
T

116 lines
4.3 KiB
React

/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
import { IconSearch, IconMinus } from '@tabler/icons-react';
/**
* Выбор иконки бренда с поиском и предпросмотром.
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
*/
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const selected = allowNone && !value
? { id: '', label: 'Без иконки', Icon: IconMinus }
: getIconById(value);
const filteredIcons = useMemo(() => {
const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter(
(item) =>
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
);
}, [search]);
const filtered = useMemo(() => {
if (!allowNone) return filteredIcons;
const q = (search || '').trim().toLowerCase();
const noneMatches = q.length === 0 || 'без иконки'.includes(q) || 'none'.includes(q);
if (!noneMatches) return filteredIcons;
return [{ id: '', label: 'Без иконки', Icon: IconMinus }, ...filteredIcons];
}, [allowNone, filteredIcons, search]);
const handleSelect = (item) => {
onChange?.(item.id);
setOpen(false);
setSearch('');
};
const panelRef = useRef(null);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('button[aria-expanded]')) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div className="mb-2">
<label className="form-label small">{label}</label>
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
<button
type="button"
className="btn btn-outline-secondary d-flex align-items-center gap-2"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="avatar bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center" style={{ width: 34, height: 34 }}>
<selected.Icon size={20} stroke={1.5} />
</span>
<span>{selected.label}</span>
</button>
{open && (
<div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 560, maxWidth: 640 }}>
<div className="input-group input-group-sm mb-2">
<span className="input-group-text">
<IconSearch size={14} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по названию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div
className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 320 }}
role="listbox"
>
{filtered.map((item) => (
<button
key={item.id}
type="button"
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
}`}
style={{ width: 56, height: 56 }}
onClick={() => handleSelect(item)}
title={item.label}
role="option"
aria-selected={value === item.id}
>
<item.Icon size={26} stroke={1.5} />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="small text-muted py-2">Ничего не найдено</div>
)}
</div>
)}
</div>
</div>
);
}
export { getIconById };