feat(web): иконки и базовые компоненты дизайн-системы (#65)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 00:04:23 +04:00
parent 6b158599bf
commit 13e7ae35bc
13 changed files with 402 additions and 2 deletions

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { applyTheme, readTheme, saveTheme } from "./theme.ts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyTheme, effectiveDark, readTheme, saveTheme } from "./theme.ts";
describe("тема", () => {
afterEach(() => document.documentElement.removeAttribute("data-theme"));
@ -20,4 +20,12 @@ describe("тема", () => {
saveTheme("dark");
expect(localStorage.getItem("graph-theme")).toBe("dark");
});
it("effectiveDark: системная следует matchMedia, light — всегда светлая, dark — тёмная", () => {
vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("dark"), media: q }));
expect(effectiveDark("")).toBe(true);
expect(effectiveDark("light")).toBe(false);
expect(effectiveDark("dark")).toBe(true);
vi.stubGlobal("matchMedia", undefined);
expect(effectiveDark("")).toBe(false);
});
});

View File

@ -18,3 +18,11 @@ export function applyTheme(t: Theme): void {
if (t) document.documentElement.setAttribute("data-theme", t);
else document.documentElement.removeAttribute("data-theme");
}
export function systemDark(): boolean {
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches;
}
export function effectiveDark(t: Theme): boolean {
return t === "dark" || (t === "" && systemDark());
}

17
web/src/ui/Badge.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { IconClose } from "./icons.tsx";
export function Badge({ tone = "neutral", children }: { tone?: "neutral" | "success" | "error" | "warning" | "info"; children: ReactNode }) {
return <span className={`ui-badge ui-badge--${tone}`}>{children}</span>;
}
export function Chip({ children, onRemove, removeLabel }: { children: ReactNode; onRemove(): void; removeLabel: string }) {
return (
<span className="ui-chip">
{children}
<button type="button" className="ui-chip__x" aria-label={removeLabel} title={removeLabel} onClick={onRemove}>
<IconClose size={16} />
</button>
</span>
);
}

26
web/src/ui/Button.tsx Normal file
View File

@ -0,0 +1,26 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonProps = {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "md" | "lg";
icon?: ReactNode;
loading?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ variant = "secondary", size = "md", icon, loading, className, children, disabled, type = "button", ...rest }: ButtonProps) {
const cls = ["ui-btn", `ui-btn--${variant}`, `ui-btn--${size}`, className].filter(Boolean).join(" ");
return (
<button {...rest} type={type} className={cls} disabled={disabled || loading} aria-busy={loading ? "true" : undefined}>
{loading ? <span className="ui-spinner" aria-hidden="true" /> : icon}
{children}
</button>
);
}
export function IconButton({ label, icon, className, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button {...rest} type={type} className={["ui-icon-btn", className].filter(Boolean).join(" ")} aria-label={label} title={label}>
{icon}
</button>
);
}

20
web/src/ui/Notice.tsx Normal file
View File

@ -0,0 +1,20 @@
import type { ReactNode } from "react";
import { IconCheck, IconError, IconInfo, IconWarning } from "./icons.tsx";
const ICON = { success: IconCheck, error: IconError, warning: IconWarning, info: IconInfo };
export function Notice({ tone, title, children, action }: {
tone: "success" | "error" | "warning" | "info"; title?: ReactNode; children?: ReactNode; action?: ReactNode;
}) {
const I = ICON[tone];
return (
<div className={`ui-notice ui-notice--${tone}`} role={tone === "error" || tone === "warning" ? "alert" : "status"}>
<span className="ui-notice__icon"><I size={16} /></span>
<div className="ui-notice__body">
{title && <div className="ui-notice__title">{title}</div>}
{children && <div className="ui-notice__text">{children}</div>}
</div>
{action && <div className="ui-notice__action">{action}</div>}
</div>
);
}

16
web/src/ui/PageHeader.tsx Normal file
View File

@ -0,0 +1,16 @@
import type { ReactNode } from "react";
export function PageHeader({ title, subtitle, actions, children }: { title: ReactNode; subtitle?: ReactNode; actions?: ReactNode; children?: ReactNode }) {
return (
<header className="ui-page-header">
<div className="ui-page-header__row">
<div className="ui-page-header__text">
<h1 className="ui-page-header__title">{title}</h1>
{subtitle && <p className="ui-page-header__subtitle">{subtitle}</p>}
</div>
{actions && <div className="ui-page-header__actions">{actions}</div>}
</div>
{children}
</header>
);
}

5
web/src/ui/Panel.tsx Normal file
View File

@ -0,0 +1,5 @@
import type { HTMLAttributes } from "react";
export function Panel({ as: Tag = "div", padding = "md", className, ...rest }: { as?: "div" | "section"; padding?: "none" | "md" | "lg" } & HTMLAttributes<HTMLElement>) {
return <Tag {...rest} className={["ui-panel", `ui-panel--${padding}`, className].filter(Boolean).join(" ")} />;
}

29
web/src/ui/Segmented.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Link } from "react-router";
type Option<T extends string> = { value: T; label: string; href?: string };
export function Segmented<T extends string>({ label, value, options, onChange }: {
label: string; value: T; options: Option<T>[]; onChange?(v: T): void;
}) {
if (options.some((o) => o.href)) {
return (
<nav className="ui-seg" aria-label={label}>
{options.map((o) => (
<Link key={o.value} to={o.href ?? "#"} className="ui-seg__item" aria-current={o.value === value ? "page" : undefined}>
{o.label}
</Link>
))}
</nav>
);
}
return (
<div className="ui-seg" role="radiogroup" aria-label={label}>
{options.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value} className="ui-seg__item"
onClick={() => onChange?.(o.value)}>
{o.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,19 @@
import { useState } from "react";
import { applyTheme, effectiveDark, readTheme, saveTheme, type Theme } from "../shell/theme.ts";
import { IconMoon, IconSun } from "./icons.tsx";
export function ThemeSwitch() {
const [theme, setTheme] = useState<Theme>(readTheme);
const dark = effectiveDark(theme);
const toggle = () => {
const next: Theme = dark ? "light" : "dark";
saveTheme(next);
applyTheme(next);
setTheme(next);
};
return (
<button type="button" role="switch" aria-checked={dark} aria-label="Тёмная тема" className="ui-theme" onClick={toggle}>
<span className="ui-theme__knob">{dark ? <IconMoon size={16} /> : <IconSun size={16} />}</span>
</button>
);
}

35
web/src/ui/icons.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { ReactNode } from "react";
// иконки дизайн-системы: React-SVG 24x24, штрих currentColor 1.5; без dangerouslySetInnerHTML
export type IconProps = { size?: 16 | 20 };
function make(children: ReactNode) {
return ({ size = 20 }: IconProps) => (
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{children}</svg>
);
}
export const IconRefresh = make(<><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></>);
export const IconUpload = make(<><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><path d="M4 20h16"/></>);
export const IconMore = make(<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>);
export const IconClose = make(<><path d="M6 6l12 12M18 6L6 18"/></>);
export const IconChevronDown = make(<><path d="M6 9l6 6 6-6"/></>);
export const IconSearch = make(<><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/></>);
export const IconFilter = make(<><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>);
export const IconSun = make(<><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></>);
export const IconMoon = make(<><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"/></>);
export const IconExternal = make(<><path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></>);
export const IconWarning = make(<><path d="M12 3.5l9.5 16.5h-19z"/><path d="M12 10v5M12 17.5v.5"/></>);
export const IconCheck = make(<><path d="M5 12.5l4.5 4.5L19 7.5"/></>);
export const IconError = make(<><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></>);
export const IconInfo = make(<><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 7.5v.5"/></>);
export const IconHandoff = make(<><path d="M4 8h13"/><path d="M13 4l4 4-4 4"/><path d="M20 16H7"/><path d="M11 12l-4 4 4 4"/></>);
export const IconCritical = make(<><path d="M13 3L5 13.5h6L10 21l8-10.5h-6z"/></>);
export const IconCalendar = make(<><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/></>);
export const IconNeighbors = make(<><circle cx="12" cy="12" r="2.5"/><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="20" r="1.5"/><path d="M10 10.5L6.5 7.5M14 10.5l3.5-3M12 14.5v4"/></>);
export const IconLink = make(<><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></>);
export const IconGrid = make(<><rect x="4" y="4" width="6.5" height="6.5" rx="1"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1"/></>);
export const IconReset = make(<><path d="M4 12a8 8 0 1 1 2.3 5.7"/><path d="M4 19v-7h7"/></>);
export const IconLegend = make(<><path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".8"/><circle cx="4.5" cy="12" r=".8"/><circle cx="4.5" cy="18" r=".8"/></>);
export const IconOpen = make(<><path d="M5 12h14"/><path d="M13 6l6 6-6 6"/></>);

10
web/src/ui/index.ts Normal file
View File

@ -0,0 +1,10 @@
import "./ui.css";
export * from "./icons.tsx";
export { Button, IconButton, type ButtonProps } from "./Button.tsx";
export { Segmented } from "./Segmented.tsx";
export { ThemeSwitch } from "./ThemeSwitch.tsx";
export { Badge, Chip } from "./Badge.tsx";
export { Notice } from "./Notice.tsx";
export { Panel } from "./Panel.tsx";
export { PageHeader } from "./PageHeader.tsx";

120
web/src/ui/ui.css Normal file
View File

@ -0,0 +1,120 @@
/* базовые компоненты дизайн-системы AURA: только переменные из styles/tokens.css, веса 400/600/700 */
.ui-btn, .ui-icon-btn, .ui-seg__item, .ui-theme, .ui-chip__x {
font-family: var(--font-ui); cursor: pointer;
transition: background var(--motion), border-color var(--motion), color var(--motion);
}
.ui-btn:focus-visible, .ui-icon-btn:focus-visible, .ui-seg__item:focus-visible, .ui-theme:focus-visible, .ui-chip__x:focus-visible {
outline: 2px solid var(--color-focus-ring); outline-offset: 2px;
}
.ui-btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
height: var(--control-h); padding: 0 var(--space-4); border: 1px solid transparent; border-radius: var(--radius-round);
font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 600; white-space: nowrap;
}
.ui-btn--lg { height: var(--control-h-lg); padding: 0 var(--space-5); }
.ui-btn--primary { background: var(--color-action-primary); color: var(--color-text-on-primary); }
.ui-btn--primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.ui-btn--secondary { background: transparent; border-color: var(--color-border-strong); color: var(--color-text-primary); }
.ui-btn--secondary:hover:not(:disabled) { border-color: var(--color-action-primary); }
.ui-btn--secondary[aria-pressed="true"] {
background: var(--color-bg-selected); border-color: var(--color-action-primary); color: var(--color-action-primary);
}
.ui-btn--danger { background: var(--color-action-danger); color: var(--color-text-on-primary); }
.ui-btn--danger:hover:not(:disabled) { background: var(--color-action-danger-hover); }
.ui-btn--ghost { background: transparent; color: var(--color-action-primary); }
.ui-btn--ghost:hover:not(:disabled) { background: var(--color-bg-hover); color: var(--color-action-primary-hover); }
.ui-btn:disabled { cursor: not-allowed; color: var(--color-text-disabled); }
.ui-btn--primary:disabled, .ui-btn--danger:disabled { background: var(--color-bg-track); border-color: var(--color-border-default); }
.ui-btn[aria-busy="true"] { cursor: progress; }
.ui-spinner {
width: 16px; height: 16px; border-radius: var(--radius-round);
border: 2px solid var(--color-border-strong); border-top-color: var(--color-action-primary);
animation: ui-spin 800ms linear infinite;
}
@keyframes ui-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ui-spinner { animation-duration: 2400ms; } }
.ui-icon-btn {
display: inline-flex; align-items: center; justify-content: center; width: var(--control-h); height: var(--control-h);
padding: 0; border: 1px solid transparent; border-radius: var(--control-radius); background: transparent; color: var(--color-text-secondary);
}
.ui-icon-btn:hover:not(:disabled) { background: var(--color-bg-hover); border-color: var(--color-border-strong); color: var(--color-text-primary); }
.ui-icon-btn:disabled { cursor: not-allowed; color: var(--color-text-disabled); }
.ui-seg {
display: inline-flex; gap: var(--space-1); padding: var(--space-1); border-radius: var(--radius-round); background: var(--color-bg-track);
}
.ui-seg__item {
display: inline-flex; align-items: center; height: 24px; padding: 0 var(--space-4); border: 1px solid transparent;
border-radius: var(--radius-round); background: transparent; color: var(--color-text-secondary);
font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 400; text-decoration: none; white-space: nowrap;
}
.ui-seg__item:hover { color: var(--color-text-primary); }
.ui-seg__item[aria-checked="true"], .ui-seg__item[aria-current="page"] {
background: var(--color-bg-surface); border-color: var(--color-border-selected); color: var(--color-action-primary); font-weight: 600;
}
.ui-theme {
display: inline-flex; align-items: center; width: 44px; height: 24px; padding: 2px; border: 1px solid var(--color-border-strong);
border-radius: var(--radius-round); background: var(--color-bg-track); color: var(--color-text-secondary);
}
.ui-theme__knob {
display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-round);
background: var(--color-bg-surface); box-shadow: inset 0 0 0 1px var(--color-border-default); transition: transform var(--motion);
}
.ui-theme[aria-checked="true"] { border-color: var(--color-border-selected); color: var(--color-action-primary); }
.ui-theme[aria-checked="true"] .ui-theme__knob { transform: translateX(20px); }
.ui-badge {
display: inline-flex; align-items: center; height: 20px; padding: 0 var(--space-2); border: 1px solid transparent;
border-radius: var(--radius-round); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 600; white-space: nowrap;
}
.ui-badge--neutral { background: var(--color-bg-track); border-color: var(--color-border-default); color: var(--color-text-secondary); }
.ui-badge--success { background: var(--color-status-success-bg); border-color: var(--color-status-success); color: var(--color-status-success); }
.ui-badge--error { background: var(--color-status-error-bg); border-color: var(--color-status-error); color: var(--color-status-error); }
.ui-badge--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); color: var(--color-status-warning); }
.ui-badge--info { background: var(--color-bg-subtle); border-color: var(--color-border-strong); color: var(--color-text-primary); }
.ui-chip {
display: inline-flex; align-items: center; gap: var(--space-1); height: 24px; padding: 0 var(--space-1) 0 var(--space-3);
border: 1px solid var(--color-border-selected); border-radius: var(--radius-round); background: var(--color-bg-selected);
color: var(--color-action-primary); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 600;
}
.ui-chip__x {
display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: 0;
border-radius: var(--radius-round); background: transparent; color: inherit;
}
.ui-chip__x:hover { background: var(--color-bg-hover); }
.ui-notice {
display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-bg-subtle);
}
.ui-notice__icon {
display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 20px;
border-radius: var(--radius-round); color: var(--color-text-on-primary); background: var(--color-text-secondary);
}
.ui-notice__body { flex: 1; min-width: 0; }
.ui-notice__title { font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 600; color: var(--color-text-primary); }
.ui-notice__text { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); }
.ui-notice__action { flex: none; }
.ui-notice--success { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
.ui-notice--success .ui-notice__icon { background: var(--color-status-success); }
.ui-notice--error { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
.ui-notice--error .ui-notice__icon { background: var(--color-status-error); }
.ui-notice--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); }
.ui-notice--warning .ui-notice__icon { background: var(--color-status-warning); }
.ui-panel {
background: var(--color-bg-surface); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl);
}
.ui-panel--md { padding: var(--space-4); }
.ui-panel--lg { padding: var(--space-6); }
.ui-panel--none { padding: 0; }
.ui-page-header { display: flex; flex-direction: column; gap: var(--space-4); margin-bottom: var(--space-4); }
.ui-page-header__row { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.ui-page-header__text { min-width: 0; }
.ui-page-header__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); }
.ui-page-header__subtitle { margin: var(--space-1) 0 0; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-text-secondary); }
.ui-page-header__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }

87
web/src/ui/ui.test.tsx Normal file
View File

@ -0,0 +1,87 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Button, Chip, IconButton, IconRefresh, Notice, Segmented, ThemeSwitch } from "./index.ts";
function stubMatchMedia(dark: boolean) {
vi.stubGlobal("matchMedia", (q: string) => ({ matches: dark && q.includes("dark"), media: q, addEventListener() {}, removeEventListener() {} }));
}
describe("Button", () => {
it("loading: aria-busy, недоступна, иконка не показана", () => {
render(<Button loading icon={<IconRefresh />}>Обновить</Button>);
const b = screen.getByRole("button", { name: /Обновить/ });
expect(b.getAttribute("aria-busy")).toBe("true");
expect((b as HTMLButtonElement).disabled).toBe(true);
expect(b.querySelector("svg")).toBeNull();
expect(b.querySelector(".ui-spinner")).not.toBeNull();
});
it("по умолчанию type=button, aria-pressed пробрасывается", () => {
render(<Button aria-pressed={true}>Фильтр</Button>);
const b = screen.getByRole("button", { name: "Фильтр" });
expect(b.getAttribute("type")).toBe("button");
expect(b.getAttribute("aria-pressed")).toBe("true");
expect(b.className).toContain("ui-btn--secondary");
});
});
describe("IconButton", () => {
it("доступное имя из label", () => {
render(<IconButton label="Закрыть" icon={<IconRefresh />} />);
expect(screen.getByRole("button", { name: "Закрыть" }).getAttribute("title")).toBe("Закрыть");
});
});
describe("Segmented", () => {
const options = [{ value: "a", label: "А" }, { value: "b", label: "Б" }];
it("кнопками: radiogroup, onChange, aria-checked", () => {
const onChange = vi.fn();
render(<Segmented label="Режим" value="a" options={options} onChange={onChange} />);
expect(screen.getByRole("radiogroup", { name: "Режим" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "А" }).getAttribute("aria-checked")).toBe("true");
expect(screen.getByRole("radio", { name: "Б" }).getAttribute("aria-checked")).toBe("false");
fireEvent.click(screen.getByRole("radio", { name: "Б" }));
expect(onChange).toHaveBeenCalledWith("b");
});
it("ссылками: nav, aria-current у активной", () => {
render(
<MemoryRouter initialEntries={["/b"]}>
<Segmented label="Разделы" value="b" options={[{ value: "a", label: "А", href: "/a" }, { value: "b", label: "Б", href: "/b" }]} />
</MemoryRouter>,
);
expect(screen.getByRole("navigation", { name: "Разделы" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Б" }).getAttribute("aria-current")).toBe("page");
expect(screen.getByRole("link", { name: "А" }).hasAttribute("aria-current")).toBe(false);
});
});
describe("ThemeSwitch", () => {
afterEach(() => document.documentElement.removeAttribute("data-theme"));
it("системная тёмная -> checked; щелчки переключают light и dark", () => {
stubMatchMedia(true);
render(<ThemeSwitch />);
const sw = screen.getByRole("switch", { name: "Тёмная тема" });
expect(sw.getAttribute("aria-checked")).toBe("true");
fireEvent.click(sw);
expect(localStorage.getItem("graph-theme")).toBe("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
expect(sw.getAttribute("aria-checked")).toBe("false");
fireEvent.click(sw);
expect(localStorage.getItem("graph-theme")).toBe("dark");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
});
describe("Chip и Notice", () => {
it("Chip: кнопка снятия вызывает onRemove", () => {
const onRemove = vi.fn();
render(<Chip onRemove={onRemove} removeLabel="Снять фильтр Спринт">Спринт</Chip>);
fireEvent.click(screen.getByRole("button", { name: "Снять фильтр Спринт" }));
expect(onRemove).toHaveBeenCalledOnce();
});
it("Notice: error — alert, info — status", () => {
render(<><Notice tone="error" title="Сбой">текст</Notice><Notice tone="info">инфо</Notice></>);
expect(screen.getByRole("alert").textContent).toContain("Сбой");
expect(screen.getByRole("status").textContent).toContain("инфо");
});
});