116 lines
4.3 KiB
React
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 };
|