feat(sf-fe-a3): UI primitives — HeadlineBar + Badge + Drawer
This commit is contained in:
parent
96b03872ac
commit
e003e4114a
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