баги
This commit is contained in:
@@ -19,6 +19,7 @@ export function AsyncCombobox({
|
||||
const inputRef = useRef(null);
|
||||
const debounceTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const restoringFocus = useRef(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -38,8 +39,8 @@ export function AsyncCombobox({
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
});
|
||||
|
||||
async function search(nextQuery) {
|
||||
@@ -73,16 +74,24 @@ export function AsyncCombobox({
|
||||
function choose(item) {
|
||||
onSelect(item);
|
||||
close();
|
||||
restoreInputFocus();
|
||||
}
|
||||
|
||||
function restoreInputFocus() {
|
||||
restoringFocus.current = true;
|
||||
inputRef.current?.focus();
|
||||
restoringFocus.current = false;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onSelect(null);
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
restoreInputFocus();
|
||||
void search('');
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (restoringFocus.current || open) return;
|
||||
setQuery(selectedLabel);
|
||||
void search(selectedLabel);
|
||||
}
|
||||
@@ -90,6 +99,12 @@ export function AsyncCombobox({
|
||||
function handleInput(event) {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
// Старый ответ не должен стать доступен во время задержки нового поиска.
|
||||
requestNumber.current += 1;
|
||||
setOpen(true);
|
||||
setItems([]);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
void search(value);
|
||||
@@ -98,6 +113,7 @@ export function AsyncCombobox({
|
||||
|
||||
function handleInputKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
@@ -111,7 +127,7 @@ export function AsyncCombobox({
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
inputRef.current?.focus();
|
||||
restoreInputFocus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
@@ -122,7 +138,13 @@ export function AsyncCombobox({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<div
|
||||
className={`async-combobox${open ? ' is-open' : ''}`}
|
||||
ref={rootRef}
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
||||
}}
|
||||
>
|
||||
<div className="async-combobox-control">
|
||||
<input
|
||||
type="search"
|
||||
|
||||
171
frontend/react/shared/ui/CustomSelect.jsx
Normal file
171
frontend/react/shared/ui/CustomSelect.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Селект дизайн-системы: разметкой и состоянием полностью управляет React. */
|
||||
export function CustomSelect({ children, ...props }) {
|
||||
const rootRef = useRef(null);
|
||||
const selectRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const listId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState({ options: [], selectedIndex: -1, label: '' });
|
||||
|
||||
// Читаем итоговые option после commit: children могут содержать массивы и фрагменты.
|
||||
// Контролируемые value, disabled и подписи синхронизируются при каждом рендере.
|
||||
useLayoutEffect(() => {
|
||||
const select = selectRef.current;
|
||||
const next = {
|
||||
options: Array.from(select.options, option => ({
|
||||
value: option.value,
|
||||
text: option.text,
|
||||
disabled: option.disabled || Boolean(option.closest('optgroup')?.disabled)
|
||||
})),
|
||||
selectedIndex: select.selectedIndex,
|
||||
label: Array.from(select.labels || [], label => {
|
||||
const caption = label.cloneNode(true);
|
||||
caption.querySelectorAll('select, .custom-select-wrapper').forEach(node => node.remove());
|
||||
return caption.textContent.trim();
|
||||
}).join(' ')
|
||||
};
|
||||
setSnapshot(current => JSON.stringify(current) === JSON.stringify(next) ? current : next);
|
||||
if (select.validity.valid) setInvalid(false);
|
||||
});
|
||||
|
||||
const expanded = open && !props.disabled;
|
||||
useEffect(() => {
|
||||
if (!expanded) return undefined;
|
||||
function closeOutside(event) {
|
||||
if (!rootRef.current?.contains(event.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
return () => document.removeEventListener('pointerdown', closeOutside);
|
||||
}, [expanded]);
|
||||
|
||||
const selected = snapshot.options[snapshot.selectedIndex];
|
||||
const searchable = snapshot.options.length > 6;
|
||||
|
||||
function choose(index) {
|
||||
const select = selectRef.current;
|
||||
select.selectedIndex = index;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setQuery('');
|
||||
setOpen(current => !current);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
} else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
if (!expanded) {
|
||||
setQuery('');
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
const options = Array.from(rootRef.current.querySelectorAll('[role="option"]:not(:disabled)'));
|
||||
const current = options.indexOf(document.activeElement);
|
||||
const index = event.key === 'Home' ? 0
|
||||
: event.key === 'End' ? options.length - 1
|
||||
: event.key === 'ArrowUp' ? (current < 0 ? options.length - 1 : Math.max(0, current - 1))
|
||||
: Math.min(options.length - 1, current + 1);
|
||||
options[index]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`react-custom-select${expanded ? ' select-layer-active' : ''}`}
|
||||
ref={rootRef}
|
||||
onKeyDown={handleKeydown}
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<select
|
||||
{...props}
|
||||
ref={selectRef}
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
data-native-select="true"
|
||||
onInvalid={event => {
|
||||
props.onInvalid?.(event);
|
||||
event.preventDefault();
|
||||
setInvalid(true);
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<div className={`custom-select-wrapper${expanded ? ' open' : ''}${props.disabled ? ' disabled' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`custom-select-trigger${!selected?.value ? ' placeholder-active' : ''}`}
|
||||
ref={triggerRef}
|
||||
disabled={props.disabled}
|
||||
role="combobox"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listId}
|
||||
aria-haspopup="listbox"
|
||||
aria-required={props.required || undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? `${listId}-error` : undefined}
|
||||
aria-label={props['aria-label'] || snapshot.label || 'Выбор значения'}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="custom-select-text">{selected?.text || '—'}</span>
|
||||
<span className="custom-select-icon" aria-hidden="true">
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="custom-select-menu">
|
||||
{searchable && (
|
||||
<input
|
||||
className="custom-select-search-input"
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
aria-label="Поиск по списку"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<ul className="react-select-options" role="listbox" id={listId}>
|
||||
{snapshot.options.filter(option => option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru'))).length === 0 && (
|
||||
<li className="custom-select-item disabled">Ничего не найдено</li>
|
||||
)}
|
||||
{snapshot.options.map((option, index) => (
|
||||
option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru')) && (
|
||||
<li key={index} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
className={`custom-select-item${index === snapshot.selectedIndex ? ' selected' : ''}${option.disabled ? ' disabled' : ''}`}
|
||||
role="option"
|
||||
aria-selected={index === snapshot.selectedIndex}
|
||||
disabled={option.disabled}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{option.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{invalid && <span id={`${listId}-error`} className="form-alert error" role="alert">Выберите значение</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomSelect } from './CustomSelect.jsx';
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export function Pagination({
|
||||
@@ -25,14 +26,14 @@ export function Pagination({
|
||||
<div className="pagination-actions">
|
||||
<label className="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select
|
||||
<CustomSelect
|
||||
value={pageSize}
|
||||
onChange={event => onSize?.(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZES.map(value => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</CustomSelect>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
16
frontend/react/shared/ui/SidebarToggle.jsx
Normal file
16
frontend/react/shared/ui/SidebarToggle.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function SidebarToggle({ open, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-secondary menu-toggle"
|
||||
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
|
||||
aria-expanded={open}
|
||||
aria-controls="app-sidebar"
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
40
frontend/react/shared/use-sidebar.js
Normal file
40
frontend/react/shared/use-sidebar.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
/** Сворачивание на десктопе и открытие мобильного меню — независимые состояния. */
|
||||
export function useSidebar() {
|
||||
const [collapsed, setCollapsed] = useState(() => (
|
||||
typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 768px)');
|
||||
const close = () => setMobileOpen(false);
|
||||
media.addEventListener('change', close);
|
||||
return () => media.removeEventListener('change', close);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return undefined;
|
||||
const close = event => {
|
||||
if (event.key === 'Escape') setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', close);
|
||||
return () => document.removeEventListener('keydown', close);
|
||||
}, [mobileOpen]);
|
||||
|
||||
function toggleSidebar() {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
setMobileOpen(current => !current);
|
||||
} else {
|
||||
setCollapsed(current => {
|
||||
localStorage.setItem(STORAGE_KEY, String(!current));
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { collapsed, mobileOpen, toggleSidebar, closeSidebar: () => setMobileOpen(false) };
|
||||
}
|
||||
Reference in New Issue
Block a user