From 2548f71f40828e4a594fd377e4e64d61a9c644bb Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 22:06:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf-fe-a3):=20UI=20primitives=20=E2=80=94?= =?UTF-8?q?=20HeadlineBar=20+=20Badge=20+=20Drawer=20(#341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ui/Badge.tsx | 92 +++++++++ frontend/src/components/ui/Drawer.tsx | 186 ++++++++++++++++++ frontend/src/components/ui/HeadlineBar.tsx | 62 ++++++ .../components/ui/__tests__/Badge.test.tsx | 52 +++++ .../components/ui/__tests__/Drawer.test.tsx | 60 ++++++ .../ui/__tests__/HeadlineBar.test.tsx | 37 ++++ 6 files changed, 489 insertions(+) create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Drawer.tsx create mode 100644 frontend/src/components/ui/HeadlineBar.tsx create mode 100644 frontend/src/components/ui/__tests__/Badge.test.tsx create mode 100644 frontend/src/components/ui/__tests__/Drawer.test.tsx create mode 100644 frontend/src/components/ui/__tests__/HeadlineBar.test.tsx diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 00000000..69580e6f --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -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 ( + + {icon && ( + + {icon} + + )} + {children} + + ); +} diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx new file mode 100644 index 00000000..9b6817fc --- /dev/null +++ b/frontend/src/components/ui/Drawer.tsx @@ -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(null); + const previousFocusRef = useRef(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(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) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const handleDrawerKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const drawer = drawerRef.current; + if (!drawer) return; + const focusable = Array.from( + drawer.querySelectorAll(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 ( +