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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user