feat(sf-fe-a3): UI primitives — HeadlineBar + Badge + Drawer #341

Merged
lekss361 merged 1 commit from feat/sf-fe-a3-primitives into main 2026-05-17 22:06:53 +00:00
6 changed files with 489 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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");
});
});

View 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)");
});
});

View 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();
});
});