fix: comprehensive UI/UX accessibility and usability improvements
Public site: skip-to-content link, mobile menu focus trap + Escape key, aria-current on nav, keyboard navigation for carousels/tabs/articles, ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog), form labels + aria-describedby, 44px touch targets, semantic HTML (<time>, <del>), prefers-reduced-motion on Hero scroll hijack, mobile schedule filters, URL hash sync on scroll for correct refresh. Admin panel: password toggle aria-label, toast aria-live regions, SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid on validation errors, sidebar hamburger aria-label/expanded, nav aria-label, ArrayEditor aria-expanded on collapsible items.
This commit is contained in:
@@ -17,6 +17,10 @@ interface ArrayEditorProps<T> {
|
||||
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||
hiddenItems?: Set<number>;
|
||||
addPosition?: "top" | "bottom";
|
||||
/** Render grip + content + delete on a single row (compact mode) */
|
||||
inline?: boolean;
|
||||
/** Hide the add button (when parent manages adding) */
|
||||
hideAdd?: boolean;
|
||||
}
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
@@ -31,6 +35,8 @@ export function ArrayEditor<T>({
|
||||
getItemBadge,
|
||||
hiddenItems,
|
||||
addPosition = "bottom",
|
||||
inline = false,
|
||||
hideAdd = false,
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
@@ -167,52 +173,80 @@ export function ArrayEditor<T>({
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
} ${isHidden ? "hidden" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{inline ? (
|
||||
/* Inline: grip + content + delete on one row */
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -243,22 +277,34 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
const isCollapsed = collapsible && collapsed.has(i);
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||
"border-white/10"
|
||||
}`}
|
||||
>
|
||||
{collapsible ? (
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
{inline ? (
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
@@ -267,18 +313,32 @@ export function ArrayEditor<T>({
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{collapsible && (
|
||||
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -312,6 +372,7 @@ export function ArrayEditor<T>({
|
||||
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
>
|
||||
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||
</button>
|
||||
@@ -320,7 +381,7 @@ export function ArrayEditor<T>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addPosition === "top" && (
|
||||
{!hideAdd && addPosition === "top" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -344,7 +405,7 @@ export function ArrayEditor<T>({
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
{addPosition === "bottom" && (
|
||||
{!hideAdd && addPosition === "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -86,16 +86,20 @@ export function ParticipantLimits({
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||||
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||||
aria-describedby="min-hint"
|
||||
aria-invalid={minEmpty || undefined}
|
||||
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||||
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||||
aria-describedby="max-hint"
|
||||
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
|
||||
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,6 +176,7 @@ export function SelectField({
|
||||
}: SelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -185,6 +190,31 @@ export function SelectField({
|
||||
|
||||
const showSearch = options.length > 3;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(0); return; }
|
||||
setHighlightIndex((prev) => (prev + 1) % filtered.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
|
||||
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
}
|
||||
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||
e.preventDefault();
|
||||
onChange(filtered[highlightIndex].value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
@@ -217,8 +247,12 @@ export function SelectField({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? search : selectedLabel}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
|
||||
onFocus={() => { setOpen(true); setSearch(""); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
@@ -228,6 +262,9 @@ export function SelectField({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
@@ -237,7 +274,7 @@ export function SelectField({
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
@@ -246,16 +283,20 @@ export function SelectField({
|
||||
<button
|
||||
key={opt.value || `opt-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightIndex(idx)}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||
}`}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function SectionEditor<T>({
|
||||
|
||||
{/* Fixed toast popup */}
|
||||
{(status === "saved" || status === "error") && (
|
||||
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
status === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<ToastContext.Provider value={{ showError, showSuccess }}>
|
||||
{children}
|
||||
{toasts.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
@@ -54,6 +54,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
aria-label="Закрыть уведомление"
|
||||
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
||||
className="shrink-0 text-neutral-400 hover:text-white"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user