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:
2026-03-29 20:42:14 +03:00
parent 024424c578
commit 77ad2a6b68
30 changed files with 538 additions and 418 deletions
+48 -7
View File
@@ -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>