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

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AdminBackButton } from '../components/admin/AdminBackButton';
import { useFocusTrap } from '../hooks/useFocusTrap';
import {
banSystemApi,
type BanSystemStatus,
@@ -198,6 +199,9 @@ export default function AdminBanSystem() {
const [stats, setStats] = useState<BanSystemStats | null>(null);
const [users, setUsers] = useState<BanUsersListResponse | 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 [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
@@ -1498,15 +1502,21 @@ export default function AdminBanSystem() {
onClick={() => setSelectedUser(null)}
>
<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"
onClick={(e) => e.stopPropagation()}
>
<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')}
</h3>
<button
onClick={() => setSelectedUser(null)}
aria-label={t('common.close')}
className="text-dark-400 hover:text-dark-200"
>
<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 { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useFocusTrap } from '@/hooks/useFocusTrap';
const BackIcon = () => (
<svg
@@ -64,6 +65,9 @@ export default function AdminRoles() {
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
onEscape: () => setDeleteConfirm(null),
});
// Queries
const {
@@ -248,8 +252,15 @@ export default function AdminRoles() {
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
<div
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')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>