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
+4 -2
View File
@@ -6,6 +6,7 @@ interface ButtonProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
disabled?: boolean;
}
const sizes = {
@@ -20,8 +21,9 @@ export function Button({
children,
className = "",
onClick,
disabled,
}: ButtonProps) {
const classes = `btn-primary ${sizes[size]} ${className}`;
const classes = `btn-primary ${sizes[size]} disabled:opacity-50 disabled:cursor-not-allowed ${className}`;
if (href) {
return (
@@ -32,7 +34,7 @@ export function Button({
}
return (
<button onClick={onClick} className={classes}>
<button onClick={onClick} className={classes} disabled={disabled}>
{children}
</button>
);
+1 -1
View File
@@ -51,7 +51,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
<button
onClick={onClose}
aria-label="Закрыть"
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
+20
View File
@@ -11,6 +11,7 @@ interface ShowcaseLayoutProps<T> {
renderDetail: (item: T, index: number) => React.ReactNode;
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
counter?: boolean;
getItemLabel?: (item: T, index: number) => string;
}
export function ShowcaseLayout<T>({
@@ -21,6 +22,7 @@ export function ShowcaseLayout<T>({
renderDetail,
renderSelectorItem,
counter = false,
getItemLabel,
}: ShowcaseLayoutProps<T>) {
const selectorRef = useRef<HTMLDivElement>(null);
const activeItemRef = useRef<HTMLButtonElement>(null);
@@ -121,6 +123,20 @@ export function ShowcaseLayout<T>({
[activeIndex, items.length, onSelect],
);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
if (activeIndex > 0) onSelect(activeIndex - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
if (activeIndex < items.length - 1) onSelect(activeIndex + 1);
}
},
[activeIndex, items.length, onSelect],
);
function handleMouseEnter() {
setIsUserInteracting(true);
onHoverChange?.(true);
@@ -136,12 +152,14 @@ export function ShowcaseLayout<T>({
className="flex flex-col-reverse gap-6 lg:flex-row lg:gap-8"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
{/* Detail area */}
<div className="lg:w-[60%]">
<div
ref={detailWrapRef}
style={minHeight != null ? { minHeight } : undefined}
aria-live="polite"
>
<div
ref={detailRef}
@@ -182,6 +200,8 @@ export function ShowcaseLayout<T>({
key={i}
ref={i === activeIndex ? activeItemRef : null}
onClick={() => onSelect(i)}
aria-label={getItemLabel ? getItemLabel(item, i) : `Элемент ${i + 1}`}
aria-pressed={i === activeIndex}
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
i === activeIndex
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
+23 -10
View File
@@ -153,7 +153,7 @@ export function SignupModal({
<button
onClick={handleClose}
aria-label="Закрыть"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
@@ -223,29 +223,40 @@ export function SignupModal({
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
<div>
<label htmlFor="signup-name" className="sr-only">Ваше имя</label>
<input
id="signup-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
aria-required="true"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="relative">
<label htmlFor="signup-phone" className="sr-only">Телефон</label>
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
id="signup-phone"
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
aria-required="true"
aria-describedby={error && error !== "network" ? "error-phone" : undefined}
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="relative">
<label htmlFor="signup-instagram" className="sr-only">Instagram (необязательно)</label>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
id="signup-instagram"
type="text"
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
@@ -254,8 +265,10 @@ export function SignupModal({
/>
</div>
<div className="relative">
<label htmlFor="signup-telegram" className="sr-only">Telegram (необязательно)</label>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
id="signup-telegram"
type="text"
value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
@@ -266,7 +279,7 @@ export function SignupModal({
</div>
{error && error !== "network" && (
<p className="text-sm text-red-400">{error}</p>
<p id="error-phone" className="text-sm text-red-400">{error}</p>
)}
<button