diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts index ed437f6..d02e485 100644 --- a/web/src/shell/theme.test.ts +++ b/web/src/shell/theme.test.ts @@ -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); + }); }); diff --git a/web/src/shell/theme.ts b/web/src/shell/theme.ts index 9e986e1..82389a4 100644 --- a/web/src/shell/theme.ts +++ b/web/src/shell/theme.ts @@ -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()); +} diff --git a/web/src/ui/Badge.tsx b/web/src/ui/Badge.tsx new file mode 100644 index 0000000..f302c37 --- /dev/null +++ b/web/src/ui/Badge.tsx @@ -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 {children}; +} + +export function Chip({ children, onRemove, removeLabel }: { children: ReactNode; onRemove(): void; removeLabel: string }) { + return ( + + {children} + + + ); +} diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..db23e20 --- /dev/null +++ b/web/src/ui/Button.tsx @@ -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; + +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 ( + + ); +} + +export function IconButton({ label, icon, className, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/web/src/ui/Notice.tsx b/web/src/ui/Notice.tsx new file mode 100644 index 0000000..621d36d --- /dev/null +++ b/web/src/ui/Notice.tsx @@ -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 ( +
+ +
+ {title &&
{title}
} + {children &&
{children}
} +
+ {action &&
{action}
} +
+ ); +} diff --git a/web/src/ui/PageHeader.tsx b/web/src/ui/PageHeader.tsx new file mode 100644 index 0000000..2b84263 --- /dev/null +++ b/web/src/ui/PageHeader.tsx @@ -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 ( +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ {children} +
+ ); +} diff --git a/web/src/ui/Panel.tsx b/web/src/ui/Panel.tsx new file mode 100644 index 0000000..7f5faa4 --- /dev/null +++ b/web/src/ui/Panel.tsx @@ -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) { + return ; +} diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx new file mode 100644 index 0000000..149afe4 --- /dev/null +++ b/web/src/ui/Segmented.tsx @@ -0,0 +1,29 @@ +import { Link } from "react-router"; + +type Option = { value: T; label: string; href?: string }; + +export function Segmented({ label, value, options, onChange }: { + label: string; value: T; options: Option[]; onChange?(v: T): void; +}) { + if (options.some((o) => o.href)) { + return ( + + ); + } + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} diff --git a/web/src/ui/ThemeSwitch.tsx b/web/src/ui/ThemeSwitch.tsx new file mode 100644 index 0000000..2a94ad5 --- /dev/null +++ b/web/src/ui/ThemeSwitch.tsx @@ -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(readTheme); + const dark = effectiveDark(theme); + const toggle = () => { + const next: Theme = dark ? "light" : "dark"; + saveTheme(next); + applyTheme(next); + setTheme(next); + }; + return ( + + ); +} diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx new file mode 100644 index 0000000..fc1d3a3 --- /dev/null +++ b/web/src/ui/icons.tsx @@ -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) => ( + + ); +} + +export const IconRefresh = make(<>); +export const IconUpload = make(<>); +export const IconMore = make(<>); +export const IconClose = make(<>); +export const IconChevronDown = make(<>); +export const IconSearch = make(<>); +export const IconFilter = make(<>); +export const IconSun = make(<>); +export const IconMoon = make(<>); +export const IconExternal = make(<>); +export const IconWarning = make(<>); +export const IconCheck = make(<>); +export const IconError = make(<>); +export const IconInfo = make(<>); +export const IconHandoff = make(<>); +export const IconCritical = make(<>); +export const IconCalendar = make(<>); +export const IconNeighbors = make(<>); +export const IconLink = make(<>); +export const IconGrid = make(<>); +export const IconReset = make(<>); +export const IconLegend = make(<>); +export const IconOpen = make(<>); diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts new file mode 100644 index 0000000..f4b616f --- /dev/null +++ b/web/src/ui/index.ts @@ -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"; diff --git a/web/src/ui/ui.css b/web/src/ui/ui.css new file mode 100644 index 0000000..d7a9340 --- /dev/null +++ b/web/src/ui/ui.css @@ -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); } diff --git a/web/src/ui/ui.test.tsx b/web/src/ui/ui.test.tsx new file mode 100644 index 0000000..02ba47f --- /dev/null +++ b/web/src/ui/ui.test.tsx @@ -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(); + 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(); + 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(} />); + 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(); + 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( + + + , + ); + 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(); + 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(Спринт); + fireEvent.click(screen.getByRole("button", { name: "Снять фильтр Спринт" })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + it("Notice: error — alert, info — status", () => { + render(<>текстинфо); + expect(screen.getByRole("alert").textContent).toContain("Сбой"); + expect(screen.getByRole("status").textContent).toContain("инфо"); + }); +});