feat(a11y,i18n): more modal focus-traps + Telegram theme/language sync

Modal a11y:
- focus-trap + role=dialog/aria-modal/aria-labelledby on AdminRoles delete confirm
  and AdminBanSystem user-detail modal (+ close aria-label)

Telegram theme/language sync (first run only, explicit choice always wins):
- getTelegramColorScheme() / getTelegramLanguageCode() helpers in useTelegramSDK
- useTheme initial state follows the Telegram client color scheme when no stored theme
- applyTelegramLanguage() adopts the Telegram client language when no stored choice,
  called from main.tsx after SDK init (launch params unavailable before init)
This commit is contained in:
c0mrade
2026-05-25 23:31:38 +03:00
parent 8e0b63bac8
commit 0caba3dffb
6 changed files with 96 additions and 4 deletions

View File

@@ -14,6 +14,7 @@ import {
expandViewport, expandViewport,
retrieveLaunchParams, retrieveLaunchParams,
retrieveRawInitData, retrieveRawInitData,
themeParamsState,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
@@ -66,6 +67,46 @@ export function getTelegramInitData(): string | null {
} }
} }
function isDarkHexColor(hex: string): boolean {
const m = hex.replace('#', '');
const full = m.length === 3 ? m.replace(/(.)/g, '$1$1') : m;
if (full.length !== 6) return false;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
// Perceived sRGB luminance; below 0.5 reads as a dark surface.
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
}
/**
* The Telegram client's effective color scheme ('light' | 'dark'), derived from
* the theme background color. Returns null outside Telegram or before theme params load.
*/
export function getTelegramColorScheme(): 'light' | 'dark' | null {
if (!detectTelegram()) return null;
try {
const bg = themeParamsState()?.bgColor;
return bg ? (isDarkHexColor(bg) ? 'dark' : 'light') : null;
} catch {
return null;
}
}
/**
* The user's Telegram client language as a 2-letter code (e.g. 'en'), or null
* outside Telegram / when unavailable.
*/
export function getTelegramLanguageCode(): string | null {
if (!detectTelegram()) return null;
try {
const user = retrieveLaunchParams().tgWebAppData?.user as { languageCode?: string } | undefined;
const code = user?.languageCode;
return code ? code.split('-')[0].toLowerCase() : null;
} catch {
return null;
}
}
export type TelegramPlatform = export type TelegramPlatform =
| 'android' | 'android'
| 'ios' | 'ios'

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'; import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors'; import { themeColorsApi } from '../api/themeColors';
import { STORAGE_KEYS } from '../config/constants'; import { STORAGE_KEYS } from '../config/constants';
import { getTelegramColorScheme } from './useTelegramSDK';
type Theme = 'dark' | 'light'; type Theme = 'dark' | 'light';
@@ -74,6 +75,13 @@ export function useTheme() {
if (stored && !enabled[stored]) { if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light'; return enabled.dark ? 'dark' : 'light';
} }
// No stored preference: follow the Telegram client's color scheme in a Mini App.
if (!stored) {
const tgScheme = getTelegramColorScheme();
if (tgScheme && enabled[tgScheme]) {
return tgScheme;
}
}
// Check system preference // Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) { if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light'; return 'light';

View File

@@ -1,6 +1,7 @@
import i18n, { type ResourceLanguage } from 'i18next'; import i18n, { type ResourceLanguage } from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import { getTelegramLanguageCode } from './hooks/useTelegramSDK';
const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = { const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
ru: () => import('./locales/ru.json'), ru: () => import('./locales/ru.json'),
@@ -11,6 +12,7 @@ const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>
const SUPPORTED_LANGS = Object.keys(localeLoaders); const SUPPORTED_LANGS = Object.keys(localeLoaders);
const FALLBACK_LNG = 'ru'; const FALLBACK_LNG = 'ru';
const LANGUAGE_STORAGE_KEY = 'cabinet_language';
const loadedLanguages = new Set<string>(); const loadedLanguages = new Set<string>();
@@ -61,4 +63,21 @@ i18n.on('languageChanged', (lng: string) => {
loadLanguage(code); loadLanguage(code);
}); });
/**
* On first run inside Telegram (no explicit stored choice), adopt the user's
* Telegram client language. Must be called after the Telegram SDK is initialised
* (e.g. from main.tsx), since launch params are unavailable before init().
*/
export function applyTelegramLanguage(): void {
try {
if (localStorage.getItem(LANGUAGE_STORAGE_KEY)) return; // explicit choice wins
} catch {
return;
}
const code = getTelegramLanguageCode();
if (code && SUPPORTED_LANGS.includes(code) && i18n.language?.split('-')[0] !== code) {
i18n.changeLanguage(code);
}
}
export default i18n; export default i18n;

View File

@@ -24,7 +24,7 @@ import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding'; import { initLogoPreload } from './api/branding';
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK'; import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
import './i18n'; import { applyTelegramLanguage } from './i18n';
import './styles/globals.css'; import './styles/globals.css';
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
@@ -55,6 +55,9 @@ if (isTelegramEnv && !alreadyInitialized) {
clearStaleSessionIfNeeded(retrieveRawInitData() || null); clearStaleSessionIfNeeded(retrieveRawInitData() || null);
// Adopt the user's Telegram client language on first run (no explicit choice yet).
applyTelegramLanguage();
// Each mount in its own try/catch so one failure doesn't block others. // Each mount in its own try/catch so one failure doesn't block others.
// mountMiniApp() internally mounts themeParams in SDK v3, // mountMiniApp() internally mounts themeParams in SDK v3,
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError. // so we don't call mountThemeParams() separately to avoid ConcurrentCallError.

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AdminBackButton } from '../components/admin/AdminBackButton'; import { AdminBackButton } from '../components/admin/AdminBackButton';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { import {
banSystemApi, banSystemApi,
type BanSystemStatus, type BanSystemStatus,
@@ -198,6 +199,9 @@ export default function AdminBanSystem() {
const [stats, setStats] = useState<BanSystemStats | null>(null); const [stats, setStats] = useState<BanSystemStats | null>(null);
const [users, setUsers] = useState<BanUsersListResponse | null>(null); const [users, setUsers] = useState<BanUsersListResponse | null>(null);
const [selectedUser, setSelectedUser] = useState<BanUserDetailResponse | null>(null); const [selectedUser, setSelectedUser] = useState<BanUserDetailResponse | null>(null);
const userDetailRef = useFocusTrap<HTMLDivElement>(selectedUser !== null, {
onEscape: () => setSelectedUser(null),
});
const [punishments, setPunishments] = useState<BanPunishmentsListResponse | null>(null); const [punishments, setPunishments] = useState<BanPunishmentsListResponse | null>(null);
const [nodes, setNodes] = useState<BanNodesListResponse | null>(null); const [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null); const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
@@ -1498,15 +1502,21 @@ export default function AdminBanSystem() {
onClick={() => setSelectedUser(null)} onClick={() => setSelectedUser(null)}
> >
<div <div
ref={userDetailRef}
role="dialog"
aria-modal="true"
aria-labelledby="ban-user-detail-title"
tabIndex={-1}
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-dark-700 bg-dark-800" className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-dark-700 bg-dark-800"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-dark-700 p-4"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<h3 className="text-lg font-semibold text-dark-100"> <h3 id="ban-user-detail-title" className="text-lg font-semibold text-dark-100">
{t('banSystem.userDetail.title')} {t('banSystem.userDetail.title')}
</h3> </h3>
<button <button
onClick={() => setSelectedUser(null)} onClick={() => setSelectedUser(null)}
aria-label={t('common.close')}
className="text-dark-400 hover:text-dark-200" className="text-dark-400 hover:text-dark-200"
> >
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -6,6 +6,7 @@ import { rbacApi } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate'; import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions'; import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform'; import { usePlatform } from '@/platform/hooks/usePlatform';
import { useFocusTrap } from '@/hooks/useFocusTrap';
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
@@ -64,6 +65,9 @@ export default function AdminRoles() {
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
onEscape: () => setDeleteConfirm(null),
});
// Queries // Queries
const { const {
@@ -248,8 +252,15 @@ export default function AdminRoles() {
onClick={() => setDeleteConfirm(null)} onClick={() => setDeleteConfirm(null)}
aria-hidden="true" aria-hidden="true"
/> />
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6"> <div
<h3 className="mb-2 text-lg font-semibold text-dark-100"> ref={deleteDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="role-delete-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6"
>
<h3 id="role-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.roles.confirm.title')} {t('admin.roles.confirm.title')}
</h3> </h3>
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p> <p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>