feat(sf-fe-a3): UI primitives — HeadlineBar + Badge + Drawer #341
6 changed files with 489 additions and 0 deletions
92
frontend/src/components/ui/Badge.tsx
Normal file
92
frontend/src/components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type BadgeVariant =
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "danger"
|
||||||
|
| "info"
|
||||||
|
| "neutral";
|
||||||
|
export type BadgeSize = "sm" | "md";
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
variant: BadgeVariant;
|
||||||
|
size?: BadgeSize;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<
|
||||||
|
BadgeVariant,
|
||||||
|
{ background: string; color: string; border: string }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
background: "var(--success-soft)",
|
||||||
|
color: "var(--success)",
|
||||||
|
border: "transparent",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: "var(--warn-soft)",
|
||||||
|
color: "var(--warn)",
|
||||||
|
border: "transparent",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
background: "var(--danger-soft)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
border: "transparent",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
background: "var(--accent-soft)",
|
||||||
|
color: "var(--accent)",
|
||||||
|
border: "transparent",
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
border: "var(--border-card)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_STYLES: Record<
|
||||||
|
BadgeSize,
|
||||||
|
{ fontSize: number; padding: string; gap: number }
|
||||||
|
> = {
|
||||||
|
sm: { fontSize: 11, padding: "2px 6px", gap: 3 },
|
||||||
|
md: { fontSize: 12, padding: "3px 8px", gap: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge — inline-flex semantic badge with variants aligned to design tokens.
|
||||||
|
* Variants: success | warning | danger | info | neutral.
|
||||||
|
* Icon slot accepts any ReactNode (use Lucide icons, size 12-14px).
|
||||||
|
*/
|
||||||
|
export function Badge({ variant, size = "md", icon, children }: BadgeProps) {
|
||||||
|
const vs = VARIANT_STYLES[variant];
|
||||||
|
const ss = SIZE_STYLES[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: ss.gap,
|
||||||
|
background: vs.background,
|
||||||
|
color: vs.color,
|
||||||
|
border: `1px solid ${vs.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: ss.padding,
|
||||||
|
fontSize: ss.fontSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/components/ui/Drawer.tsx
Normal file
186
frontend/src/components/ui/Drawer.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type CSSProperties,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export interface DrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
side: "right" | "bottom";
|
||||||
|
/** CSS width string — default "360px" for right, "100%" for bottom */
|
||||||
|
width?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTORS =
|
||||||
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drawer — accessible slide-in overlay panel.
|
||||||
|
* - side="right" → right side panel (default 360px width)
|
||||||
|
* - side="bottom" → bottom-sheet (full width, mobile-first)
|
||||||
|
* Click outside / Escape → onClose.
|
||||||
|
* Focus trap: Tab cycles within open drawer.
|
||||||
|
*/
|
||||||
|
export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousFocusRef = useRef<Element | null>(null);
|
||||||
|
|
||||||
|
// Save focus and restore on close
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
previousFocusRef.current = document.activeElement;
|
||||||
|
// Move focus into drawer on next frame
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
const el =
|
||||||
|
drawerRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
|
||||||
|
el?.focus();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
} else {
|
||||||
|
if (previousFocusRef.current instanceof HTMLElement) {
|
||||||
|
previousFocusRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll while open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const drawer = drawerRef.current;
|
||||||
|
if (!drawer) return;
|
||||||
|
const focusable = Array.from(
|
||||||
|
drawer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS),
|
||||||
|
).filter((el) => !el.closest("[aria-hidden]"));
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0] as HTMLElement;
|
||||||
|
const last = focusable[focusable.length - 1] as HTMLElement;
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultWidth = side === "right" ? "360px" : "100%";
|
||||||
|
const resolvedWidth = width ?? defaultWidth;
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties =
|
||||||
|
side === "right"
|
||||||
|
? {
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: resolvedWidth,
|
||||||
|
maxWidth: "100vw",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
boxShadow: "0 0 32px rgba(0,0,0,0.18)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
transform: open ? "translateX(0)" : "translateX(100%)",
|
||||||
|
transition: "transform 180ms ease",
|
||||||
|
zIndex: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: "85vh",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
|
||||||
|
borderRadius: "12px 12px 0 0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
transform: open ? "translateY(0)" : "translateY(100%)",
|
||||||
|
transition: "transform 180ms ease",
|
||||||
|
zIndex: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
// Keep in DOM for transition but aria-hide
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 199,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={drawerRef} style={panelStyle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.32)",
|
||||||
|
zIndex: 199,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: side === "bottom" ? "flex-end" : "stretch",
|
||||||
|
justifyContent: side === "right" ? "flex-end" : "stretch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={drawerRef}
|
||||||
|
style={panelStyle}
|
||||||
|
onKeyDown={handleDrawerKeyDown}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/ui/HeadlineBar.tsx
Normal file
62
frontend/src/components/ui/HeadlineBar.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface HeadlineBarProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeadlineBar — dark verdict/headline strip used across Site Finder and analytics.
|
||||||
|
* Background: var(--bg-headline) = #0F172A (slate-900).
|
||||||
|
* Text: var(--fg-on-dark) / var(--fg-on-dark-muted).
|
||||||
|
*/
|
||||||
|
export function HeadlineBar({ title, subtitle, rightSlot }: HeadlineBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-headline)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: subtitle ? "flex-start" : "center",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--fg-on-dark)",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
color: "var(--fg-on-dark-muted)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{rightSlot && (
|
||||||
|
<div style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>
|
||||||
|
{rightSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/components/ui/__tests__/Badge.test.tsx
Normal file
52
frontend/src/components/ui/__tests__/Badge.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||||
|
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||||
|
*/
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Badge } from "../Badge";
|
||||||
|
|
||||||
|
describe("Badge", () => {
|
||||||
|
it("renders children text", () => {
|
||||||
|
render(<Badge variant="success">Свободен</Badge>);
|
||||||
|
expect(screen.getByText("Свободен")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies success token background via inline style", () => {
|
||||||
|
const { container } = render(<Badge variant="success">OK</Badge>);
|
||||||
|
const span = container.querySelector("span");
|
||||||
|
expect(span?.style.background).toBe("var(--success-soft)");
|
||||||
|
expect(span?.style.color).toBe("var(--success)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies danger token for danger variant", () => {
|
||||||
|
const { container } = render(<Badge variant="danger">Риск</Badge>);
|
||||||
|
const span = container.querySelector("span");
|
||||||
|
expect(span?.style.background).toBe("var(--danger-soft)");
|
||||||
|
expect(span?.style.color).toBe("var(--danger)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders icon slot when provided", () => {
|
||||||
|
render(
|
||||||
|
<Badge variant="warning" icon={<span data-testid="icon" />}>
|
||||||
|
Внимание
|
||||||
|
</Badge>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies sm size with smaller font", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Badge variant="neutral" size="sm">
|
||||||
|
Нейтрально
|
||||||
|
</Badge>,
|
||||||
|
);
|
||||||
|
const span = container.querySelector("span");
|
||||||
|
expect(span?.style.fontSize).toBe("11px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies md size by default", () => {
|
||||||
|
const { container } = render(<Badge variant="info">Инфо</Badge>);
|
||||||
|
const span = container.querySelector("span");
|
||||||
|
expect(span?.style.fontSize).toBe("12px");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/src/components/ui/__tests__/Drawer.test.tsx
Normal file
60
frontend/src/components/ui/__tests__/Drawer.test.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||||
|
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||||
|
*/
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { Drawer } from "../Drawer";
|
||||||
|
|
||||||
|
describe("Drawer", () => {
|
||||||
|
it("renders children when open=true", () => {
|
||||||
|
render(
|
||||||
|
<Drawer open={true} onClose={() => {}} side="right">
|
||||||
|
<p>Контент панели</p>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Контент панели")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show children interactively when open=false", () => {
|
||||||
|
render(
|
||||||
|
<Drawer open={false} onClose={() => {}} side="right">
|
||||||
|
<p>Скрытый контент</p>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
// Panel is in DOM but aria-hidden overlay
|
||||||
|
const overlay = document.querySelector('[aria-hidden="true"]');
|
||||||
|
expect(overlay).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
render(
|
||||||
|
<Drawer open={true} onClose={onClose} side="right">
|
||||||
|
<p>Контент</p>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with role=dialog when open", () => {
|
||||||
|
render(
|
||||||
|
<Drawer open={true} onClose={() => {}} side="bottom">
|
||||||
|
<p>Bottom sheet</p>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translateY(0) for bottom side when open", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Drawer open={true} onClose={() => {}} side="bottom">
|
||||||
|
<p>Bottom</p>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
// The panel div is second child of dialog div
|
||||||
|
const dialog = container.querySelector('[role="dialog"]');
|
||||||
|
const panel = dialog?.querySelector("div");
|
||||||
|
expect(panel?.style.transform).toBe("translateY(0)");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
Normal file
37
frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||||
|
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||||
|
*/
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { HeadlineBar } from "../HeadlineBar";
|
||||||
|
|
||||||
|
describe("HeadlineBar", () => {
|
||||||
|
it("renders title text", () => {
|
||||||
|
render(<HeadlineBar title="ПОДХОДИТ · участок 0.82 га" />);
|
||||||
|
expect(screen.getByText("ПОДХОДИТ · участок 0.82 га")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders subtitle when provided", () => {
|
||||||
|
render(
|
||||||
|
<HeadlineBar
|
||||||
|
title="Заголовок"
|
||||||
|
subtitle="Кадастровая стоимость 14.2 млн ₽"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Кадастровая стоимость 14.2 млн ₽"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render subtitle section when omitted", () => {
|
||||||
|
const { container } = render(<HeadlineBar title="Только заголовок" />);
|
||||||
|
// subtitle div has marginTop style — not present when subtitle is undefined
|
||||||
|
const subtitleDivs = container.querySelectorAll("div > div > div");
|
||||||
|
expect(subtitleDivs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders rightSlot content", () => {
|
||||||
|
render(<HeadlineBar title="Title" rightSlot={<button>Export</button>} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue