refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
@@ -1,258 +0,0 @@
|
||||
.content {
|
||||
max-width: 1120px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.bottomNav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portfolioIcon {
|
||||
color: var(--mantine-color-blue-7);
|
||||
}
|
||||
|
||||
.cardsIcon {
|
||||
color: var(--mantine-color-grape-7);
|
||||
|
||||
& svg {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.rubles {
|
||||
pointer-events: none;
|
||||
color: var(--mantine-color-green-8);
|
||||
stroke: currentColor;
|
||||
|
||||
& path {
|
||||
opacity: 0;
|
||||
--flight-x: -7px;
|
||||
--flight-rotation: -20deg;
|
||||
--flight-delay: 0ms;
|
||||
}
|
||||
|
||||
& path:nth-child(2) {
|
||||
--flight-x: 0px;
|
||||
--flight-rotation: 8deg;
|
||||
--flight-delay: 120ms;
|
||||
}
|
||||
|
||||
& path:nth-child(3) {
|
||||
--flight-x: 7px;
|
||||
--flight-rotation: 24deg;
|
||||
--flight-delay: 240ms;
|
||||
}
|
||||
}
|
||||
|
||||
.stocksIcon {
|
||||
color: var(--mantine-color-green-8);
|
||||
}
|
||||
|
||||
.bondsIcon {
|
||||
color: var(--mantine-color-orange-8);
|
||||
}
|
||||
|
||||
.settingsIcon {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
.portfolioRuble {
|
||||
stroke: var(--mantine-color-green-8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stockArrow {
|
||||
transform-origin: 21px 5px;
|
||||
}
|
||||
|
||||
.topSlider,
|
||||
.bottomSlider {
|
||||
fill: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.navLink {
|
||||
transition: background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
.drawStroke,
|
||||
.couponLines path {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
|
||||
.topSlider {
|
||||
--slider-offset: 3px;
|
||||
--slider-delay: 0ms;
|
||||
}
|
||||
|
||||
.bottomSlider {
|
||||
--slider-offset: -3px;
|
||||
--slider-delay: 160ms;
|
||||
}
|
||||
|
||||
.couponLines path:nth-child(2) {
|
||||
--line-delay: 140ms;
|
||||
}
|
||||
|
||||
.couponLines path:nth-child(3) {
|
||||
--line-delay: 280ms;
|
||||
}
|
||||
|
||||
svg[data-icon-animating] {
|
||||
.rubles path {
|
||||
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
|
||||
}
|
||||
.portfolioHandle {
|
||||
animation: handle-lift 1200ms ease-in-out;
|
||||
}
|
||||
.portfolioLid {
|
||||
animation: lid-open 1200ms ease-in-out;
|
||||
}
|
||||
.portfolioRuble {
|
||||
animation: portfolio-ruble-reveal 1200ms ease-in-out;
|
||||
}
|
||||
.stockArrow {
|
||||
animation: arrow-pop 300ms ease-out 400ms both;
|
||||
}
|
||||
.couponLines path {
|
||||
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
|
||||
}
|
||||
.drawStroke {
|
||||
animation: stroke-draw 450ms ease-out;
|
||||
}
|
||||
:is(.topSlider, .bottomSlider) {
|
||||
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ruble-fly {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 4px);
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handle-lift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lid-open {
|
||||
0%,
|
||||
15%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes portfolio-ruble-reveal {
|
||||
0%,
|
||||
25%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
40%,
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arrow-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stroke-draw {
|
||||
0% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slider-slide {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(var(--slider-offset));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.99375em) {
|
||||
.bottomNav {
|
||||
background: var(--mantine-color-white);
|
||||
border-top: 1px solid var(--mantine-color-gray-3);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
left: 0;
|
||||
padding: var(--mantine-spacing-xs) 2px;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
& a {
|
||||
min-width: 0;
|
||||
padding: 8px 2px;
|
||||
text-align: center;
|
||||
}
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
||||
import { bindIconAnimation } from "../lib";
|
||||
import classes from "./AppShell.module.css";
|
||||
|
||||
type AppShellProps = {
|
||||
contentAboveOutlet?: ReactNode;
|
||||
};
|
||||
|
||||
const AppShell: FC<AppShellProps> = (props) => {
|
||||
const { contentAboveOutlet } = props;
|
||||
const { t } = useTranslation();
|
||||
const { pathname } = useLocation();
|
||||
const items = [
|
||||
{
|
||||
to: "/accounts",
|
||||
label: t("navigation.accounts"),
|
||||
iconClassName: classes.portfolioIcon,
|
||||
icon: (
|
||||
<>
|
||||
<path d="M3 10v9h18v-9" />
|
||||
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
|
||||
<path
|
||||
className={classes.portfolioLid}
|
||||
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
|
||||
/>
|
||||
<path
|
||||
className={classes.portfolioRuble}
|
||||
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/cards",
|
||||
label: t("navigation.cards"),
|
||||
iconClassName: classes.cardsIcon,
|
||||
icon: (
|
||||
<>
|
||||
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
|
||||
<g className={classes.rubles}>
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/stocks",
|
||||
label: t("navigation.stocks"),
|
||||
iconClassName: classes.stocksIcon,
|
||||
icon: (
|
||||
<>
|
||||
<path d="M3 3v18h18" />
|
||||
<path
|
||||
className={classes.drawStroke}
|
||||
pathLength={1}
|
||||
d="M7 14l5-5 4 3 5-7"
|
||||
/>
|
||||
<path className={classes.stockArrow} d="M16 5h5v5" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/bonds",
|
||||
label: t("navigation.bonds"),
|
||||
iconClassName: classes.bondsIcon,
|
||||
icon: (
|
||||
<>
|
||||
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
|
||||
<g className={classes.couponLines}>
|
||||
<path pathLength={1} d="M9 7h6" />
|
||||
<path pathLength={1} d="M9 11h6" />
|
||||
<path pathLength={1} d="M9 15h3" />
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/settings",
|
||||
label: t("navigation.settings"),
|
||||
iconClassName: classes.settingsIcon,
|
||||
icon: (
|
||||
<>
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
|
||||
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<MantineAppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }}
|
||||
padding="md"
|
||||
>
|
||||
<MantineAppShell.Header className={classes.header}>
|
||||
<Text fw={700}>{t("appName")}</Text>
|
||||
</MantineAppShell.Header>
|
||||
<MantineAppShell.Navbar p="sm">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
component={RouterNavLink}
|
||||
className={classes.navLink}
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
leftSection={
|
||||
<span
|
||||
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
||||
>
|
||||
<svg
|
||||
ref={bindIconAnimation}
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{item.icon}
|
||||
</svg>
|
||||
</span>
|
||||
}
|
||||
active={pathname.startsWith(item.to)}
|
||||
/>
|
||||
))}
|
||||
</MantineAppShell.Navbar>
|
||||
<MantineAppShell.Main className={classes.main}>
|
||||
<div className={classes.content}>
|
||||
{contentAboveOutlet}
|
||||
<Outlet />
|
||||
</div>
|
||||
</MantineAppShell.Main>
|
||||
<nav className={classes.bottomNav} aria-label={t("appName")}>
|
||||
{items.map((item) => (
|
||||
<RouterNavLink
|
||||
className={
|
||||
pathname.startsWith(item.to) ? classes.active : undefined
|
||||
}
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
>
|
||||
{item.label}
|
||||
</RouterNavLink>
|
||||
))}
|
||||
</nav>
|
||||
</MantineAppShell>
|
||||
);
|
||||
};
|
||||
|
||||
export { AppShell };
|
||||
@@ -1,95 +0,0 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-md);
|
||||
border-color: var(--mantine-color-gray-3);
|
||||
background: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
.identity,
|
||||
.value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: var(--wl-color-positive);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--wl-color-negative);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@media (max-width: 36em) {
|
||||
.row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.value {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.valueFooter {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
min-height: 28px;
|
||||
}
|
||||
.valueLabel,
|
||||
.actions {
|
||||
grid-area: 1 / 1;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.valueLabel {
|
||||
opacity: 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.valueLabel {
|
||||
opacity: 1;
|
||||
}
|
||||
.actions {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.row:hover .actions,
|
||||
.row:focus-within .actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.row:hover .valueLabel,
|
||||
.row:focus-within .valueLabel {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 36em) {
|
||||
.valueFooter {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.actions button {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.valueLabel,
|
||||
.actions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import classes from "./EntryCard.module.css";
|
||||
|
||||
type EntryCardProps = {
|
||||
title: string;
|
||||
metadata: string;
|
||||
details?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
initial: string;
|
||||
icon?: ReactNode;
|
||||
color?: string;
|
||||
imageSrc?: string | null;
|
||||
value: string;
|
||||
valueLabel: string;
|
||||
tone: "positive" | "negative" | "neutral";
|
||||
};
|
||||
|
||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
return (
|
||||
<Paper
|
||||
className={classes.row}
|
||||
withBorder
|
||||
style={
|
||||
props.color
|
||||
? {
|
||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
radius="md"
|
||||
size={40}
|
||||
src={imageFailed ? null : props.imageSrc}
|
||||
onError={() => setImageFailed(true)}
|
||||
>
|
||||
{props.icon ?? props.initial}
|
||||
</Avatar>
|
||||
<Stack className={classes.identity} gap={2}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{props.title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{props.metadata}
|
||||
</Text>
|
||||
{props.details}
|
||||
</Stack>
|
||||
<Stack className={classes.value} gap={2}>
|
||||
<Text className={classes[props.tone]} fw={700}>
|
||||
{props.value}
|
||||
</Text>
|
||||
<div className={props.actions ? classes.valueFooter : undefined}>
|
||||
<Text
|
||||
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
|
||||
size="sm"
|
||||
>
|
||||
{props.valueLabel}
|
||||
</Text>
|
||||
{props.actions && (
|
||||
<div className={classes.actions}>{props.actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { EntryCard };
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Paper, Skeleton } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/LoadingSkeleton",
|
||||
component: LoadingSkeleton,
|
||||
tags: ["autodocs"],
|
||||
args: { label: "Загрузка содержимого" },
|
||||
argTypes: {
|
||||
label: { control: "text" },
|
||||
children: { table: { disable: true } },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Общая status/busy область для initial или partial loading. Передайте доступную метку и только декоративные Skeleton shapes без интерактивных элементов. Геометрию определяет владелец section/page (см. Product/AccountPageLoading). При refetch сохраняйте уже видимые данные; для действия используйте Button loading. Этот пример не запускает запрос и не демонстрирует query lifecycle.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<Paper p="md" withBorder>
|
||||
<LoadingSkeleton {...args}>
|
||||
<Skeleton h={24} w="60%" />
|
||||
<Skeleton h={72} />
|
||||
<Skeleton h={16} w="80%" />
|
||||
</LoadingSkeleton>
|
||||
</Paper>
|
||||
),
|
||||
} satisfies Meta<typeof LoadingSkeleton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Initial: Story = { name: "Загрузка области" };
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
type LoadingSkeletonProps = PropsWithChildren<{
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
const LoadingSkeleton: FC<LoadingSkeletonProps> = (props) => {
|
||||
const { children, label } = props;
|
||||
|
||||
return (
|
||||
<Stack aria-busy="true" aria-label={label} gap="md" role="status">
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoadingSkeleton };
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
type StateStoryArgs = ComponentProps<typeof StateView> & {
|
||||
withAction: boolean;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/StateView",
|
||||
component: StateView,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
title: "Нет учебных данных",
|
||||
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
|
||||
error: false,
|
||||
withAction: false,
|
||||
onAction: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
title: { control: "text" },
|
||||
text: { control: "text" },
|
||||
error: { control: "boolean" },
|
||||
action: { table: { disable: true } },
|
||||
withAction: {
|
||||
control: "boolean",
|
||||
description: "Показать необязательное действие StateView",
|
||||
},
|
||||
onAction: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Общее empty/error состояние WealthLedger с необязательным действием. Error сохраняет alert semantics. Для initial loading используйте skeleton страницы, для refetch — локальное предупреждение рядом с сохранёнными данными. Кнопка вызывает только Actions; API recovery и полный пользовательский сценарий здесь не проверяются.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => {
|
||||
const { withAction, onAction, ...stateProps } = args;
|
||||
return (
|
||||
<StateView
|
||||
{...stateProps}
|
||||
action={
|
||||
withAction ? (
|
||||
<Button onClick={onAction} variant="default">
|
||||
{args.error ? "Повторить локально" : "Выполнить пример"}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<StateStoryArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Empty: Story = { name: "Пустое состояние" };
|
||||
export const EmptyWithAction: Story = {
|
||||
name: "Пустое состояние с действием",
|
||||
args: { withAction: true },
|
||||
};
|
||||
export const Failed: Story = {
|
||||
name: "Ошибка",
|
||||
args: {
|
||||
error: true,
|
||||
title: "Учебная ошибка",
|
||||
text: "Демонстрация недоступных данных без вызова API.",
|
||||
},
|
||||
};
|
||||
export const ErrorWithAction: Story = {
|
||||
name: "Ошибка с retry",
|
||||
args: { ...Failed.args, withAction: true },
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
describe("StateView", () => {
|
||||
it("renders an accessible structural loading region", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<LoadingSkeleton label="Загрузка">
|
||||
<div />
|
||||
</LoadingSkeleton>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an accessible error state", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<StateView
|
||||
error
|
||||
text="Попробуйте обновить страницу позже."
|
||||
title="Что-то пошло не так"
|
||||
/>
|
||||
,
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type StateViewProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
action?: ReactNode;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
const StateView: FC<StateViewProps> = (props) => {
|
||||
const { title, text, action, error = false } = props;
|
||||
const role = error ? "alert" : undefined;
|
||||
|
||||
return (
|
||||
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
|
||||
<Stack align="flex-start" gap="sm">
|
||||
<Title order={2}>{title}</Title>
|
||||
<Text c="dimmed">{text}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { StateView };
|
||||
@@ -1,4 +0,0 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export { EntryCard } from "./EntryCard";
|
||||
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
export { StateView } from "./StateView";
|
||||
Reference in New Issue
Block a user