From 42e5ace6552234a5c8bc8feed2363949882dba6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 8 Sep 2026 09:32:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(notifications+a11y):=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=80=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20/notifications,=20skip-link,=20axe=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг 2.5 плана production-готовности: - страница уведомлений (фильтр, догрузка, прочтение) + ссылки из dropdown/мобильного меню - общие хелперы notification-utils (дедупликация dropdown) - skip-link + id=content на main - axe smoke-тесты (0 critical/serious), исправлены 2 нарушения в новой странице - 3 новых теста (107/107), i18n отложен по решению пользователя --- IMPLEMENTATION_LOG.md | 16 + client/src/App.tsx | 3 + client/src/components/MobileBottomNav.tsx | 8 +- .../src/components/NotificationsDropdown.tsx | 35 +- client/src/components/Sidebar.tsx | 25 +- .../SystemNotificationsDropdown.tsx | 140 +--- client/src/lib/notification-utils.tsx | 115 +++ client/src/pages/NotificationsPage.tsx | 200 ++++++ package-lock.json | 653 ++++++++++++++++++ package.json | 4 + tests/a11y.test.tsx | 172 +++++ vitest.config.ts | 5 + 12 files changed, 1249 insertions(+), 127 deletions(-) create mode 100644 client/src/lib/notification-utils.tsx create mode 100644 client/src/pages/NotificationsPage.tsx create mode 100644 tests/a11y.test.tsx diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index e950dc4..eae47eb 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -389,3 +389,19 @@ - Как проверялось: построчная программная сверка — 13/15 блоков байт-в-байт, расхождения объяснены (export-префиксы, перенос типов/констант, фрагменты вместо leading-комментариев JSX, удаление 24 неиспользуемых импортов оригинала). Чеклист: bulkSaveFormFields, правила согласующих, пресеты, QR-toggle, условия полей — дословно. check/lint/build чисто (warnings 566→542); vitest 104/104. - Влияние на поиск/UX: нет. - Итог 2.2: Chat 2696→639, TaskDetail 3278→804, Bots 3928→163, FormEditor 4975→337. + +--- + +## [2.5] Продуктовая середина: центр уведомлений + a11y + +- Статус: ✅ done (i18n-задел — ⏸ отклонено/отложено по решению пользователя: не-русские рынки не подтверждены) +- Зачем: центр уведомлений страницей, skip-link и базовый axe-проход (Фаза 2). +- Что изменено: + - `client/src/pages/NotificationsPage.tsx` — страница /notifications: фильтр все/непрочитанные (кнопки с aria-pressed), клиентская «Загрузить ещё» по 30, отметка одного/всех, переход к задаче через общий хелпер `getNotificationTarget`. Маршрут в App.tsx. + - `client/src/lib/notification-utils.tsx` — общие хелперы/хуки уведомлений (дедупликация: SystemNotificationsDropdown отрефакторен на них); ссылка «Все уведомления» в обоих dropdown + пункт в MobileBottomNav (на мобильном dropdown недоступен). + - Skip-link «Перейти к содержимому» в Sidebar.tsx (первым фокусируемым элементом, sr-only до фокуса, оба main получили id="content") — размещён в Sidebar, т.к. Layout рендерит children внутрь него. + - `tests/a11y.test.tsx` — axe smoke (jsdom) на Login/OnboardingChecklist/NotificationsPage: 0 critical/serious; инфра: axe-core, jsdom, @testing-library/react, alias @ в vitest.config + oxc jsx runtime (vitest 4/Vite 8). + - Исправлены найденные axe-нарушения: aria-valid-attr-value (Tabs без панелей → кнопки), nested-interactive (строка уведомления перестроена). +- Как проверялось: vitest 107/107; check/lint/build чисто. +- Влияние на поиск/UX: нет. +- Подводные камни: GET /api/notifications без серверной пагинации — при очень больших историях «Загрузить ещё» работает на клиенте; серверная пагинация — отдельная задача. Полный axe-проход по всему приложению — задел, не входил. diff --git a/client/src/App.tsx b/client/src/App.tsx index 7563df4..a2114f3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -63,6 +63,7 @@ const GenerationHistoryPage = safeLazy(() => import("@/pages/documents/Generatio const AssetsPage = safeLazy(() => import("@/pages/documents/AssetsPage").then(m => ({ default: m.AssetsPage }))); const PackagesPage = safeLazy(() => import("@/pages/documents/PackagesPage").then(m => ({ default: m.PackagesPage }))); const ChatPage = safeLazy(() => import("@/pages/Chat")); +const NotificationsPage = safeLazy(() => import("@/pages/NotificationsPage")); const HomePage = safeLazy(() => import("@/pages/Home")); const DashboardPage = safeLazy(() => import("@/pages/DashboardPage")); const CalendarPage = safeLazy(() => import("@/pages/CalendarPage")); @@ -91,6 +92,7 @@ const ROUTE_TITLES: Array<[RegExp, string]> = [ [/^\/med/, 'MedSchedule'], [/^\/gps/, 'GPS-мониторинг'], [/^\/chat/, 'Чат'], + [/^\/notifications/, 'Уведомления'], [/^\/users/, 'Пользователи'], [/^\/forms\/[^/]+\/tasks/, 'Задачи'], [/^\/forms/, 'Формы'], @@ -173,6 +175,7 @@ function Router() { return ( + diff --git a/client/src/components/MobileBottomNav.tsx b/client/src/components/MobileBottomNav.tsx index fc8d97a..193d71e 100644 --- a/client/src/components/MobileBottomNav.tsx +++ b/client/src/components/MobileBottomNav.tsx @@ -17,7 +17,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank, MapPin } from 'lucide-react'; +import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank, MapPin, Bell } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useBookmarkFolders, useBookmarksByFolder } from '@/hooks/useBookmarks'; import { CreateBookmarkModal } from '@/components/CreateBookmarkModal'; @@ -356,6 +356,12 @@ export function MobileBottomNav() { + + + + Уведомления + + diff --git a/client/src/components/NotificationsDropdown.tsx b/client/src/components/NotificationsDropdown.tsx index 17e440e..6d1fa9b 100644 --- a/client/src/components/NotificationsDropdown.tsx +++ b/client/src/components/NotificationsDropdown.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; +import { useLocation } from 'wouter'; import { format } from 'date-fns'; import { apiRequest, queryClient } from '@/lib/queryClient'; import { useWebNotifications } from '@/hooks/useWebNotifications'; @@ -21,6 +22,7 @@ interface NotificationsDropdownProps { export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdownProps) => { const [isOpen, setIsOpen] = useState(false); + const [, setLocation] = useLocation(); const [replyingTo, setReplyingTo] = useState(null); const [replyMessage, setReplyMessage] = useState(''); const previousNotificationsRef = useRef([]); @@ -322,24 +324,21 @@ export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdo )} - {notifications.length > 0 && ( - <> - -
- -
- - )} + +
+ +
); diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index a4f69bb..1848dbf 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1451,6 +1451,23 @@ interface SidebarProps { children?: React.ReactNode; } +/** + * Skip-link: первая фокусируемая ссылка страницы, визуально скрыта до фокуса. + * Ведёт на основной контент (main#content), чтобы пользователи клавиатуры + * и скринридеров могли пропустить навигацию. position:fixed при фокусе — + * не влияет на layout ни на desktop, ни на мобильном. + */ +function SkipToContentLink() { + return ( + + Перейти к содержимому + + ); +} + export function Sidebar({ children }: SidebarProps) { const { user, logout, isOfflineSession, isExpiredOffline } = useAuth(); const { theme, toggleTheme } = useTheme(); @@ -1476,6 +1493,7 @@ export function Sidebar({ children }: SidebarProps) { return (
+ {!isOnline && (
diff --git a/client/src/components/SystemNotificationsDropdown.tsx b/client/src/components/SystemNotificationsDropdown.tsx index bffa6a9..44995cd 100644 --- a/client/src/components/SystemNotificationsDropdown.tsx +++ b/client/src/components/SystemNotificationsDropdown.tsx @@ -1,18 +1,23 @@ import { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; import { useLocation } from 'wouter'; import { format } from 'date-fns'; -import { apiRequest, queryClient } from '@/lib/queryClient'; -import { useNotifications, AppNotification, CHAT_EVENT_TYPES } from '@/hooks/useNotifications'; +import { useNotifications, AppNotification } from '@/hooks/useNotifications'; +import { + getNotificationIcon, + getNotificationText, + getNotificationTarget, + useMarkNotificationRead, + useMarkAllNotificationsRead, +} from '@/lib/notification-utils'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { Bell, Check, UserPlus, ArrowRightLeft, FileEdit, AlertCircle, MessageSquare, AtSign, Clock } from 'lucide-react'; +import { Bell, Check } from 'lucide-react'; interface SystemNotificationsDropdownProps { collapsed?: boolean; @@ -30,100 +35,8 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific .slice(0, 30) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const markAsReadMutation = useMutation({ - mutationFn: async (notificationId: number) => { - return apiRequest('PUT', `/api/notifications/${notificationId}/read`); - }, - onMutate: async (notificationId) => { - await queryClient.cancelQueries({ queryKey: ['/api/notifications'] }); - const previous = queryClient.getQueryData(['/api/notifications']); - queryClient.setQueryData(['/api/notifications'], (old: any) => { - if (!old?.notifications) return old; - return { - ...old, - notifications: old.notifications.map((n: any) => - n.id === notificationId ? { ...n, isRead: true } : n - ), - }; - }); - return { previous }; - }, - onError: (err, notificationId, context) => { - if (context?.previous) { - queryClient.setQueryData(['/api/notifications'], context.previous); - } - console.error('[SystemNotificationsDropdown] markAsRead error:', err); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); - }, - }); - - const markAllMutation = useMutation({ - mutationFn: async () => { - return apiRequest('PUT', '/api/notifications/read-all'); - }, - onMutate: async () => { - await queryClient.cancelQueries({ queryKey: ['/api/notifications'] }); - const previous = queryClient.getQueryData(['/api/notifications']); - queryClient.setQueryData(['/api/notifications'], (old: any) => { - if (!old?.notifications) return old; - return { - ...old, - notifications: old.notifications.map((n: any) => ({ ...n, isRead: true })), - }; - }); - return { previous }; - }, - onError: (err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData(['/api/notifications'], context.previous); - } - console.error('[SystemNotificationsDropdown] markAllRead error:', err); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); - }, - }); - - const getNotificationIcon = (type: string) => { - if (CHAT_EVENT_TYPES.includes(type)) { - if (type === 'task.mentioned' || type === 'task_mention') { - return ; - } - return ; - } - switch (type) { - case 'task.assigned': - case 'task_assigned': - return ; - case 'task.status.changed': - case 'task_status': - return ; - case 'form.structure.changed': - return ; - case 'task.reminder': - return ; - default: - return ; - } - }; - - const getNotificationText = (notification: AppNotification): string => { - if (CHAT_EVENT_TYPES.includes(notification.type)) { - const chatMsg = notification.taskMessage; - if (chatMsg?.author) { - return `${chatMsg.author.firstName} ${chatMsg.author.middleName || ''} ${chatMsg.author.lastName}`.trim() + `: ${chatMsg.message || ''}`; - } - return notification.title || notification.task?.title || ''; - } - if (typeof notification.message === 'string' && notification.message) { - return notification.message.length > 100 - ? notification.message.substring(0, 100) + '...' - : notification.message; - } - return notification.title || ''; - }; + const markAsReadMutation = useMarkNotificationRead(); + const markAllMutation = useMarkAllNotificationsRead(); const markAsRead = (notification: AppNotification, e: React.MouseEvent) => { e.stopPropagation(); @@ -134,10 +47,11 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific if (!notification.isRead) { markAsReadMutation.mutate(notification.id); } - if (notification.task?.id && notification.task?.formId) { + const target = getNotificationTarget(notification); + if (target) { // SPA navigation so the mark-as-read mutation can complete and the task // detail mounts without a full page reload (which can abort in-flight fetches). - setLocation(`/forms/${notification.task.formId}/tasks/${notification.task.id}`); + setLocation(target); } setIsOpen(false); }; @@ -239,6 +153,22 @@ export const SystemNotificationsDropdown = ({ collapsed = false }: SystemNotific
)} + + +
+ +
); diff --git a/client/src/lib/notification-utils.tsx b/client/src/lib/notification-utils.tsx new file mode 100644 index 0000000..ad4fbbf --- /dev/null +++ b/client/src/lib/notification-utils.tsx @@ -0,0 +1,115 @@ +import { useMutation } from '@tanstack/react-query'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { AppNotification, CHAT_EVENT_TYPES } from '@/hooks/useNotifications'; +import { UserPlus, ArrowRightLeft, FileEdit, AlertCircle, MessageSquare, AtSign, Clock } from 'lucide-react'; + +// Общие хелперы уведомлений: используются в SystemNotificationsDropdown и на странице /notifications + +export function getNotificationIcon(type: string) { + if (CHAT_EVENT_TYPES.includes(type)) { + if (type === 'task.mentioned' || type === 'task_mention') { + return ; + } + return ; + } + switch (type) { + case 'task.assigned': + case 'task_assigned': + return ; + case 'task.status.changed': + case 'task_status': + return ; + case 'form.structure.changed': + return ; + case 'task.reminder': + return ; + default: + return ; + } +} + +export function getNotificationText(notification: AppNotification): string { + if (CHAT_EVENT_TYPES.includes(notification.type)) { + const chatMsg = notification.taskMessage; + if (chatMsg?.author) { + return `${chatMsg.author.firstName} ${chatMsg.author.middleName || ''} ${chatMsg.author.lastName}`.trim() + `: ${chatMsg.message || ''}`; + } + return notification.title || notification.task?.title || ''; + } + if (typeof notification.message === 'string' && notification.message) { + return notification.message.length > 100 + ? notification.message.substring(0, 100) + '...' + : notification.message; + } + return notification.title || ''; +} + +// Целевой маршрут уведомления (задача), null — если переходить некуда +export function getNotificationTarget(notification: AppNotification): string | null { + if (notification.task?.id && notification.task?.formId) { + return `/forms/${notification.task.formId}/tasks/${notification.task.id}`; + } + return null; +} + +// Отметить одно уведомление прочитанным (с оптимистичным обновлением кэша) +export function useMarkNotificationRead() { + return useMutation({ + mutationFn: async (notificationId: number) => { + return apiRequest('PUT', `/api/notifications/${notificationId}/read`); + }, + onMutate: async (notificationId) => { + await queryClient.cancelQueries({ queryKey: ['/api/notifications'] }); + const previous = queryClient.getQueryData(['/api/notifications']); + queryClient.setQueryData(['/api/notifications'], (old: any) => { + if (!old?.notifications) return old; + return { + ...old, + notifications: old.notifications.map((n: any) => + n.id === notificationId ? { ...n, isRead: true } : n + ), + }; + }); + return { previous }; + }, + onError: (err, _notificationId, context) => { + if (context?.previous) { + queryClient.setQueryData(['/api/notifications'], context.previous); + } + console.error('[notifications] markAsRead error:', err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); + }, + }); +} + +// Отметить все уведомления прочитанными (с оптимистичным обновлением кэша) +export function useMarkAllNotificationsRead() { + return useMutation({ + mutationFn: async () => { + return apiRequest('PUT', '/api/notifications/read-all'); + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['/api/notifications'] }); + const previous = queryClient.getQueryData(['/api/notifications']); + queryClient.setQueryData(['/api/notifications'], (old: any) => { + if (!old?.notifications) return old; + return { + ...old, + notifications: old.notifications.map((n: any) => ({ ...n, isRead: true })), + }; + }); + return { previous }; + }, + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(['/api/notifications'], context.previous); + } + console.error('[notifications] markAllRead error:', err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); + }, + }); +} diff --git a/client/src/pages/NotificationsPage.tsx b/client/src/pages/NotificationsPage.tsx new file mode 100644 index 0000000..772b159 --- /dev/null +++ b/client/src/pages/NotificationsPage.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from 'react'; +import { useLocation } from 'wouter'; +import { format } from 'date-fns'; +import Layout from '@/components/Layout'; +import { useNotifications, AppNotification } from '@/hooks/useNotifications'; +import { + getNotificationIcon, + getNotificationText, + getNotificationTarget, + useMarkNotificationRead, + useMarkAllNotificationsRead, +} from '@/lib/notification-utils'; +import { Button } from '@/components/ui/button'; +import { Bell, Check, CheckCheck, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Сколько уведомлений показываем за раз (кнопка «Показать ещё») +const PAGE_SIZE = 30; + +type Filter = 'all' | 'unread'; + +/** + * Центр уведомлений: полный список уведомлений пользователя. + * Dropdown в сайдбаре показывает только непрочитанные — здесь доступна вся история. + */ +export default function NotificationsPage() { + const [, setLocation] = useLocation(); + const [filter, setFilter] = useState('all'); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const { allNotifications, isLoading } = useNotifications(); + const markAsReadMutation = useMarkNotificationRead(); + const markAllMutation = useMarkAllNotificationsRead(); + + const sorted = useMemo( + () => + [...allNotifications].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ), + [allNotifications] + ); + + const filtered = filter === 'unread' ? sorted.filter(n => !n.isRead) : sorted; + const visible = filtered.slice(0, visibleCount); + const unreadCount = allNotifications.filter(n => !n.isRead).length; + + const handleNotificationClick = (notification: AppNotification) => { + if (!notification.isRead) { + markAsReadMutation.mutate(notification.id); + } + const target = getNotificationTarget(notification); + if (target) setLocation(target); + }; + + return ( + +
+
+

Уведомления

+

+ {unreadCount > 0 ? `Непрочитанных: ${unreadCount}` : 'Все уведомления прочитаны'} +

+
+ {unreadCount > 0 && ( + + )} +
+ +
+ + +
+ + {isLoading ? ( +
+
+ ) : filtered.length === 0 ? ( +
+
+ ) : ( + <> +
    + {visible.map((notification) => { + const text = getNotificationText(notification); + const target = getNotificationTarget(notification); + return ( +
  • + {/* Кликабельная зона — настоящая кнопка (клавиатура/скринридер), + кнопка «Прочитано» лежит рядом, а не внутри (nested-interactive) */} + + + {!notification.isRead && ( +
    + +
    + )} +
  • + ); + })} +
+ + {visibleCount < filtered.length && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/package-lock.json b/package-lock.json index e70e44c..0e49203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,8 @@ "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/archiver": "^8.0.0", "@types/bcrypt": "^6.0.0", "@types/connect-pg-simple": "^7.0.3", @@ -166,11 +168,13 @@ "@types/yandex-maps": "^2.1.36", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", + "axe-core": "^4.13.0", "drizzle-kit": "^0.31.10", "esbuild": "^0.28.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^17.12.0", + "jsdom": "^30.0.1", "postcss": "^8.4.47", "supertest": "^7.2.2", "tailwindcss": "^3.4.17", @@ -244,6 +248,59 @@ "openapi-types": ">=7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1028,6 +1085,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -2203,6 +2413,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -5796,6 +6024,54 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "3.27.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", @@ -6419,6 +6695,13 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7688,6 +7971,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7772,6 +8065,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -7953,6 +8256,16 @@ "node": ">= 18" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -8751,6 +9064,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -8899,6 +9226,35 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -9098,6 +9454,13 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -11503,6 +11866,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -11890,6 +12266,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -12021,6 +12404,57 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12728,6 +13162,16 @@ "node": ">= 6" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13087,6 +13531,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -14291,6 +14742,32 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14806,6 +15283,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/probe-image-size": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.4.0.tgz", @@ -15839,6 +16351,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -16584,6 +17109,13 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -16806,6 +17338,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -16843,6 +17395,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -17013,6 +17591,16 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -17965,6 +18553,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -18015,6 +18616,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18161,6 +18797,16 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -18199,6 +18845,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 81c353b..128d478 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,8 @@ "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/archiver": "^8.0.0", "@types/bcrypt": "^6.0.0", "@types/connect-pg-simple": "^7.0.3", @@ -174,11 +176,13 @@ "@types/yandex-maps": "^2.1.36", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", + "axe-core": "^4.13.0", "drizzle-kit": "^0.31.10", "esbuild": "^0.28.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^17.12.0", + "jsdom": "^30.0.1", "postcss": "^8.4.47", "supertest": "^7.2.2", "tailwindcss": "^3.4.17", diff --git a/tests/a11y.test.tsx b/tests/a11y.test.tsx new file mode 100644 index 0000000..d8ba4fc --- /dev/null +++ b/tests/a11y.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +/** + * Базовый axe-проход по ключевым компонентам: форма входа, онбординг-чеклист, + * страница уведомлений. Проверяем отсутствие нарушений уровня critical/serious. + * Это smoke-задел, а не полный аудит всего приложения. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import axe from 'axe-core'; + +// jsdom не имеет ResizeObserver, а Radix-контролы (Checkbox и др.) его требуют +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +// --- Моки хуков: тестируем разметку без сети и auth --- + +// Текущий «пользователь» для useAuth — переключается между тестами +const mockAuthState = vi.hoisted(() => ({ + user: null as null | Record, +})); + +vi.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ + user: mockAuthState.user, + login: vi.fn(), + isLoading: false, + isExpiredOffline: false, + }), +})); + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn() }), + toast: vi.fn(), +})); + +// Layout в странице уведомлений заменяем на passthrough — +// сайдбар с SSE/офлайн-контекстами в jsdom не нужен +vi.mock('@/components/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +const FIXTURE_NOTIFICATIONS = [ + { + id: 1, + taskId: 10, + type: 'task.assigned', + title: 'Вас назначили ответственным', + message: 'Задача «Проверить отчёт» назначена вам', + isRead: false, + createdAt: '2026-03-01T10:00:00.000Z', + task: { id: 10, title: 'Проверить отчёт', formId: 5 }, + }, + { + id: 2, + taskId: 11, + type: 'task_mention', + title: 'Вас упомянули', + isRead: true, + createdAt: '2026-02-28T09:00:00.000Z', + task: { id: 11, title: 'Согласовать договор', formId: 5 }, + taskMessage: { + id: 100, + message: 'Посмотрите, пожалуйста', + author: { firstName: 'Иван', lastName: 'Петров' }, + }, + }, +]; + +vi.mock('@/hooks/useNotifications', () => ({ + CHAT_EVENT_TYPES: ['task.mentioned', 'task.comment.created', 'task.comment.replied', 'task_mention', 'task_reply'], + useNotifications: () => ({ + allNotifications: FIXTURE_NOTIFICATIONS, + chatNotifications: [], + systemNotifications: FIXTURE_NOTIFICATIONS, + unreadChatCount: 0, + unreadSystemCount: 1, + isLoading: false, + sseConnected: true, + }), +})); + +// Импорты компонентов — после vi.mock (hoisting учтён) +import Login from '../client/src/pages/Login'; +import { OnboardingChecklist } from '../client/src/components/OnboardingChecklist'; +import NotificationsPage from '../client/src/pages/NotificationsPage'; + +afterEach(cleanup); + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + // Заглушка queryFn: тесты работают на предзагруженном кэше, сеть не нужна + queryFn: () => Promise.resolve({}), + }, + }, + }); +} + +async function runAxe(container: HTMLElement) { + const results = await axe.run(container, { + // color-contrast в jsdom не вычисляется (нет реальных стилей/canvas) — исключаем, + // чтобы не ловить ложные срабатывания; контраст проверяется скинами дизайн-системы + rules: { 'color-contrast': { enabled: false } }, + }); + return results.violations.filter( + v => v.impact === 'critical' || v.impact === 'serious' + ); +} + +describe('a11y smoke (axe)', () => { + it('Login: форма входа без критичных нарушений', async () => { + const { container } = render(); + // Защита от вакуумного прохода: форма действительно отрендерилась + expect(container.querySelector('[data-testid="input-email"]')).toBeTruthy(); + const violations = await runAxe(container); + expect( + violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) + ).toEqual([]); + }); + + it('OnboardingChecklist: чеклист без критичных нарушений', async () => { + mockAuthState.user = { + id: 1, + firstName: 'Тест', + lastName: 'Тестов', + organization: { id: 1 }, + }; + const queryClient = makeQueryClient(); + // Данные предзагружены — компонент рендерит шаги без сетевых запросов + queryClient.setQueryData(['/api/forms'], { success: true, forms: [{}] }); + queryClient.setQueryData(['/api/users/list'], { success: true, users: [{}, {}] }); + queryClient.setQueryData(['/api/tasks', 'onboarding-first'], { success: true, tasks: [] }); + + const { container } = render( + + + + ); + // Чеклист действительно виден (не скрыт по loading/dismissed) + expect(container.textContent).toContain('Начало работы'); + const violations = await runAxe(container); + expect( + violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) + ).toEqual([]); + }); + + it('NotificationsPage: центр уведомлений без критичных нарушений', async () => { + const queryClient = makeQueryClient(); + const { container } = render( + + + + ); + // Список действительно отрендерился + expect(container.querySelector('[data-testid="notification-1"]')).toBeTruthy(); + const violations = await runAxe(container); + expect( + violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) + ).toEqual([]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c900fe4..53ee6d2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,10 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + // Vite 8 (rolldown/oxc): в tsconfig jsx: 'preserve' — для тестов JSX нужно транспилировать + oxc: { + jsx: { runtime: 'automatic' }, + }, test: { setupFiles: ['./tests/setup.ts'], environment: 'node', @@ -12,6 +16,7 @@ export default defineConfig({ }, resolve: { alias: { + '@': path.resolve(__dirname, 'client/src'), '@shared': path.resolve(__dirname, 'shared'), }, },