feat(a11y): cross-platform hardening + modal focus-trap from impeccable audit

Responsive / viewport:
- add .min-h-viewport / .h-viewport utilities (100dvh + Telegram --tg-viewport-stable-height)
- migrate min-h-screen / h-screen / 100vh across 18 files
- reveal hover-only controls on focus-within + touch (desktop nav, admin settings)
- grid breakpoints (TopUpAmount, Contests), larger touch targets, nav aria-labels

Platform routing (Telegram WebView correctness):
- clipboard -> copyToClipboard util with execCommand fallback (10 files)
- window.open -> openTelegramLink / openLink (Profile, Referral, ChannelSubscriptionScreen)
- window.confirm -> useNativeDialog (admin pages); PromoOffersSection -> useDestructiveConfirm
- window.prompt -> usePrompt / PromptDialogHost (TipTap link editors)
- ESLint no-restricted-properties guard against regressions (adapters/clipboard util exempt)

Modal accessibility:
- new useFocusTrap hook (focus trap, Esc, scroll lock, focus restore)
- role=dialog / aria-modal / focus-trap: Polls, SuccessNotificationModal, AdminPolicies,
  AdminPromoGroups, AdminCampaigns, BroadcastPreview (Telegram + Email)
- new global usePrompt store + PromptDialogHost
This commit is contained in:
c0mrade
2026-05-25 23:16:07 +03:00
parent 2bcba3b60f
commit 8e0b63bac8
48 changed files with 584 additions and 173 deletions

107
src/hooks/useFocusTrap.ts Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import type { RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
interface FocusTrapOptions {
/** Called when Escape is pressed while the trap is active. */
onEscape?: () => void;
/** Lock body scroll while active. Default: true. Set false if the caller already locks scroll. */
lockScroll?: boolean;
}
/**
* Traps keyboard focus inside a container while `active`.
* - Moves focus into the container on activation, restores it on deactivation.
* - Cycles Tab / Shift+Tab within the container (no escape into the page behind).
* - Optionally closes on Escape and locks body scroll.
*
* Attach the returned ref to the dialog element and give it
* `role="dialog" aria-modal="true" tabIndex={-1}`.
*
* @example
* const dialogRef = useFocusTrap<HTMLDivElement>(isOpen, { onEscape: close });
* return <div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1}>...</div>;
*/
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
active: boolean,
options: FocusTrapOptions = {},
): RefObject<T | null> {
const { lockScroll = true } = options;
const containerRef = useRef<T>(null);
// Keep the latest onEscape without re-running the effect on every render
// (which would otherwise yank focus back to the first element repeatedly).
const onEscapeRef = useRef(options.onEscape);
onEscapeRef.current = options.onEscape;
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const getFocusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el.getClientRects().length > 0,
);
// Move focus into the dialog.
const initial = getFocusable();
(initial[0] ?? container).focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscapeRef.current) {
e.preventDefault();
onEscapeRef.current();
return;
}
if (e.key !== 'Tab') return;
const focusable = getFocusable();
if (focusable.length === 0) {
e.preventDefault();
container.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey) {
if (activeEl === first || !container.contains(activeEl)) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !container.contains(activeEl)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
let prevOverflow: string | undefined;
if (lockScroll) {
prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (lockScroll) {
document.body.style.overflow = prevOverflow ?? '';
}
previouslyFocused?.focus?.();
};
}, [active, lockScroll]);
return containerRef;
}