diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md index 43634c8..3c1d72d 100644 --- a/docs/UI-PATTERN.md +++ b/docs/UI-PATTERN.md @@ -6,6 +6,8 @@ Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние. +Initial loading без доступного результата использует page-owned structural skeleton, повторяющий semantic regions экрана (heading, cards, filters, summaries или rows), а не `StateView` или отдельный spinner. Partial loading принадлежит section view и занимает только ещё не загруженный блок. Skeleton region имеет доступное busy-обозначение, но его декоративные shapes не содержат данных, не интерактивны и не получают фокус. Route fallback сохраняет доступный application shell и skeleton-изирует content area; до auth `ready` public и protected guards показывают neutral shell skeleton. Button `loading` для mutation и compact progress при Client Cached Snapshot остаются контекстными indicators и не заменяются skeleton. + Для finance-экранов используется граница `container -> ViewModel -> view`. `*.tsx` без суффикса — container: он вызывает единственный page VM hook и выбирает top-level Loading/Error/Empty/Loaded view. `*.vm.ts` владеет data diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index ad96729..f8dc426 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -1,6 +1,7 @@ -import { Loader } from "@mantine/core"; +import { Container, Group, Skeleton, Stack } from "@mantine/core"; import type { FC, PropsWithChildren } from "react"; import { lazy, Suspense } from "react"; +import { useTranslation } from "react-i18next"; import { Navigate, Route, Routes, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; @@ -41,17 +42,77 @@ const ProtectedLayout = lazy(async () => { return { default: ProtectedLayout }; }); -const LazyPage: FC = (props) => { - const { children } = props; +type RouteSkeletonVariant = "auth" | "content" | "shell"; - return }>{children}; +type LazyPageProps = PropsWithChildren<{ + variant: RouteSkeletonVariant; +}>; + +const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { + const { variant } = props; + const { t } = useTranslation(); + + if (variant === "auth") + return ( + + + + + + + + + ); + + if (variant === "shell") + return ( + + + + + + + + + + + + + + ); + + return ( + + + + + ); +}; + +const LazyPage: FC = (props) => { + const { children, variant } = props; + + return ( + }> + {children} + + ); }; const Protected: FC = (props) => { const { children } = props; const { user, ready } = useAuth(); const location = useLocation(); - if (!ready) return null; + if (!ready) return ; return user ? ( children ) : ( @@ -66,7 +127,7 @@ const Protected: FC = (props) => { const Public: FC = (props) => { const { children } = props; const { user, ready } = useAuth(); - if (!ready) return null; + if (!ready) return ; return user ? : children; }; @@ -78,7 +139,7 @@ const AppRoutes: FC = () => { path="/login" element={ - + @@ -88,7 +149,7 @@ const AppRoutes: FC = () => { path="/register" element={ - + @@ -97,7 +158,7 @@ const AppRoutes: FC = () => { - + @@ -106,7 +167,7 @@ const AppRoutes: FC = () => { + } @@ -114,7 +175,7 @@ const AppRoutes: FC = () => { + } @@ -122,7 +183,7 @@ const AppRoutes: FC = () => { + } @@ -130,7 +191,7 @@ const AppRoutes: FC = () => { + } @@ -138,7 +199,7 @@ const AppRoutes: FC = () => { + } @@ -147,7 +208,7 @@ const AppRoutes: FC = () => { + } diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx index 5622b2b..a5ce20a 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx @@ -14,7 +14,10 @@ describe("TbankConnectionSection state views", () => { , ); - expect(screen.getByText("Пожалуйста, подождите.")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( + "aria-busy", + "true", + ); rerender( diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx index 1a0c36d..dd5c9cc 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx @@ -1,6 +1,7 @@ -import { Alert, Button, Text } from "@mantine/core"; +import { Alert, Button, Skeleton } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; +import { LoadingSkeleton } from "@/shared/ui"; type TbankConnectionErrorProps = { retry: () => void; @@ -8,7 +9,13 @@ type TbankConnectionErrorProps = { const TbankConnectionLoading: FC = () => { const { t } = useTranslation(); - return {t("states.loadingText")}; + + return ( + + + + + ); }; const TbankConnectionError: FC = (props) => { diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage.test.tsx index 49e78c6..4551e86 100644 --- a/src/pages/account-events/ui/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.test.tsx @@ -94,4 +94,19 @@ describe("AccountEventsPage", () => { screen.getByRole("button", { name: "Повторить" }).click(); expect(refetch).toHaveBeenCalledOnce(); }); + + it("shows a structural busy state before events are available", () => { + query.mockReturnValue({ + data: undefined, + isError: false, + isLoading: true, + refetch: vi.fn(), + } as never); + + renderPage(); + + const status = screen.getByRole("status", { name: "Загрузка" }); + expect(status).toHaveAttribute("aria-busy", "true"); + expect(status.querySelector("button")).toBeNull(); + }); }); diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index 85d597f..e848ce6 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -1,4 +1,12 @@ -import { Button, Card, Group, Stack, Text, Title } from "@mantine/core"; +import { + Button, + Card, + Group, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; @@ -7,7 +15,7 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { formatMoney } from "@/entities/portfolio"; -import { StateView } from "@/shared/ui"; +import { LoadingSkeleton, StateView } from "@/shared/ui"; type AccountEventsPageErrorProps = { retry: () => void; @@ -36,11 +44,13 @@ const AccountEventsPageLoading: FC = () => { const { t } = useTranslation(); return ( - + + + + {[0, 1, 2, 3].map((index) => ( + + ))} + ); }; diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx index 7fb6c1c..e9e2d7b 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx @@ -1,16 +1,29 @@ +import { Group, Skeleton, Stack } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; +import { LoadingSkeleton } from "@/shared/ui"; const AccountOperationsPageLoading: FC = () => { const { t } = useTranslation(); return ( - + + + + + + + + + + + + + {[0, 1, 2, 3].map((index) => ( + + ))} + + ); }; diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index e250f3d..f33b415 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -1,11 +1,11 @@ -import { Accordion, Alert, Button, Stack, Text } from "@mantine/core"; +import { Accordion, Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { Portfolio } from "@/entities/portfolio"; import { formatSnapshotTime } from "@/entities/portfolio"; -import { StateView } from "@/shared/ui"; +import { LoadingSkeleton, StateView } from "@/shared/ui"; import classes from "./AccountPage.module.css"; import type { AccountPagePositionSection, @@ -51,11 +51,13 @@ const AccountPageLoading: FC = () => { const { t } = useTranslation(); return ( - + + + + {[0, 1, 2].map((index) => ( + + ))} + ); }; diff --git a/src/pages/account/ui/components/RecentEvents.view.tsx b/src/pages/account/ui/components/RecentEvents.view.tsx index aec379d..adce91b 100644 --- a/src/pages/account/ui/components/RecentEvents.view.tsx +++ b/src/pages/account/ui/components/RecentEvents.view.tsx @@ -1,9 +1,10 @@ -import { Alert, Button, Paper, Stack, Text } from "@mantine/core"; +import { Alert, Button, Paper, Skeleton, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { BrokerageEvent } from "@/entities/brokerage-event"; import { formatMoney } from "@/entities/portfolio"; +import { LoadingSkeleton } from "@/shared/ui"; import classes from "../AccountPage.module.css"; import type { AccountPageRecentEvents } from "../AccountPage.vm"; @@ -22,7 +23,11 @@ const RecentEvents: FC = (props) => { return ( {events.state === "loading" ? ( - {t("events.recentLoading")} + + {[0, 1, 2].map((index) => ( + + ))} + ) : null} {events.state === "error" ? ( {t("events.recentError")} diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx index e836607..40b5cf2 100644 --- a/src/pages/account/ui/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/components/RecentOperations.view.tsx @@ -1,8 +1,9 @@ -import { Alert, Button, Stack, Text } from "@mantine/core"; +import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; +import { LoadingSkeleton } from "@/shared/ui"; import type { AccountPageRecentOperations } from "../AccountPage.vm"; type RecentOperationsProps = { @@ -16,7 +17,11 @@ const RecentOperations: FC = (props) => { return ( {operations.state === "loading" ? ( - {t("ledger.recentLoading")} + + {[0, 1, 2].map((index) => ( + + ))} + ) : null} {operations.state === "error" ? ( {t("ledger.recentError")} diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx index 2847832..aa400e3 100644 --- a/src/pages/accounts/ui/AccountsPage.view.tsx +++ b/src/pages/accounts/ui/AccountsPage.view.tsx @@ -3,6 +3,7 @@ import { Button, Group, Loader, + Skeleton, Stack, Text, Title, @@ -12,7 +13,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { BrokerageAccountCard } from "@/entities/brokerage-account"; import { BrokerageAccountList } from "@/entities/brokerage-account"; -import { StateView } from "@/shared/ui"; +import { LoadingSkeleton, StateView } from "@/shared/ui"; type AccountsPageErrorProps = { code?: string; @@ -42,11 +43,12 @@ const AccountsPageLoading: FC = () => { const { t } = useTranslation(); return ( - + + + {[0, 1, 2].map((index) => ( + + ))} + ); }; diff --git a/src/shared/ui/LoadingSkeleton.tsx b/src/shared/ui/LoadingSkeleton.tsx new file mode 100644 index 0000000..f2991a7 --- /dev/null +++ b/src/shared/ui/LoadingSkeleton.tsx @@ -0,0 +1,18 @@ +import { Stack } from "@mantine/core"; +import type { FC, PropsWithChildren } from "react"; + +type LoadingSkeletonProps = PropsWithChildren<{ + label: string; +}>; + +const LoadingSkeleton: FC = (props) => { + const { children, label } = props; + + return ( + + {children} + + ); +}; + +export { LoadingSkeleton }; diff --git a/src/shared/ui/StateView.test.tsx b/src/shared/ui/StateView.test.tsx index 3ae5c84..59d0eeb 100644 --- a/src/shared/ui/StateView.test.tsx +++ b/src/shared/ui/StateView.test.tsx @@ -1,21 +1,25 @@ import { MantineProvider } from "@mantine/core"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; +import { LoadingSkeleton } from "./LoadingSkeleton"; import { StateView } from "./StateView"; describe("StateView", () => { afterEach(cleanup); - it("renders an accessible loading state with a loader", () => { + it("renders an accessible structural loading region", () => { render( - + +
+ , ); - const status = screen.getByRole("status"); - expect(status).toHaveTextContent("Загрузка"); - expect(screen.getByLabelText("Загрузка")).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( + "aria-busy", + "true", + ); }); it("renders an accessible error state", () => { diff --git a/src/shared/ui/StateView.tsx b/src/shared/ui/StateView.tsx index b7fe0d9..a5e376a 100644 --- a/src/shared/ui/StateView.tsx +++ b/src/shared/ui/StateView.tsx @@ -1,22 +1,20 @@ -import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core"; +import { Button, Paper, Stack, Text, Title } from "@mantine/core"; import type { FC, ReactNode } from "react"; type StateViewProps = { title: string; text: string; action?: ReactNode; - loading?: boolean; error?: boolean; }; const StateView: FC = (props) => { - const { title, text, action, loading = false, error = false } = props; - const role = loading ? "status" : error ? "alert" : undefined; + const { title, text, action, error = false } = props; + const role = error ? "alert" : undefined; return ( - {loading ? : null} {title} {text} {action} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 67ebbb9..cd22dbc 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,2 +1,3 @@ export { AppShell } from "./AppShell"; +export { LoadingSkeleton } from "./LoadingSkeleton"; export { Button, StateView } from "./StateView";