refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
.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(--wl-color-border);
|
||||
background: var(--wl-color-subtle);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/EntryCard",
|
||||
component: EntryCard,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
title:
|
||||
"Учебная запись с очень длинным названием для проверки узкого экрана",
|
||||
metadata: "22.09.2026 · Синтетические данные",
|
||||
initial: "У",
|
||||
imageSrc: "data:image/png;base64,invalid",
|
||||
value: "−1 250,00 ₽",
|
||||
valueLabel: "Тестовое значение",
|
||||
tone: "negative",
|
||||
actions: (
|
||||
<Button size="compact-sm" onClick={fn()}>
|
||||
Открыть пример
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof EntryCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LongContentAndFallback: Story = {};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
|
||||
describe("EntryCard", () => {
|
||||
it("keeps the fallback after an image fails", () => {
|
||||
const props = {
|
||||
title: "Пример",
|
||||
metadata: "Описание",
|
||||
initial: "У",
|
||||
value: "—",
|
||||
valueLabel: "Неизвестно",
|
||||
tone: "neutral" as const,
|
||||
imageSrc: "data:image/png;base64,invalid",
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MantineProvider>
|
||||
<EntryCard {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Decorative image has no accessible name; trigger its actual failure event.
|
||||
const image = container.querySelector("img");
|
||||
assert(image);
|
||||
fireEvent.error(image);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<EntryCard {...props} title="Обновлённый пример" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("У")).toBeInTheDocument();
|
||||
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
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%, var(--wl-color-surface))`,
|
||||
}
|
||||
: 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 };
|
||||
Reference in New Issue
Block a user