From d6ddca07513901cd326b20d2a1d39d9201d23b5f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 00:09:46 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=BE=D0=BB=D1=8F,=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B8,=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD-=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/components/MultiSelect.tsx | 2 +- web/src/board/components/Toolbar.tsx | 2 +- web/src/styles/controls.css | 7 +- web/src/ui/Dialog.tsx | 15 ++++ .../components => ui}/Dropdown.test.tsx | 2 +- web/src/ui/Field.tsx | 15 ++++ .../components/Dropdown.tsx => ui/Menu.tsx} | 26 ++++++ web/src/ui/forms.test.tsx | 83 +++++++++++++++++++ web/src/ui/index.ts | 4 + web/src/ui/inputs.tsx | 43 ++++++++++ web/src/ui/ui.css | 71 ++++++++++++++++ 11 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 web/src/ui/Dialog.tsx rename web/src/{board/components => ui}/Dropdown.test.tsx (94%) create mode 100644 web/src/ui/Field.tsx rename web/src/{board/components/Dropdown.tsx => ui/Menu.tsx} (53%) create mode 100644 web/src/ui/forms.test.tsx create mode 100644 web/src/ui/inputs.tsx diff --git a/web/src/board/components/MultiSelect.tsx b/web/src/board/components/MultiSelect.tsx index 67d28ad..90e17b7 100644 --- a/web/src/board/components/MultiSelect.tsx +++ b/web/src/board/components/MultiSelect.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { FacetItem } from "../analysis/facets.ts"; -import { Dropdown } from "./Dropdown.tsx"; +import { Dropdown } from "../../ui/index.ts"; // мультиселект с поиском: пустой выбор = без фильтра (строки 913–938) export function MultiSelect({ items, selected, allLabel, onChange }: { diff --git a/web/src/board/components/Toolbar.tsx b/web/src/board/components/Toolbar.tsx index 8de675e..5609b66 100644 --- a/web/src/board/components/Toolbar.tsx +++ b/web/src/board/components/Toolbar.tsx @@ -3,7 +3,7 @@ import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } fro import { statusText, type BoardStatus } from "../status.ts"; import type { BoardModel, Visible } from "../types.ts"; import { NOT_IN_HUB } from "../constants.ts"; -import { Dropdown } from "./Dropdown.tsx"; +import { Dropdown } from "../../ui/index.ts"; interface ToolbarProps { label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean; diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css index 9240509..8b83067 100644 --- a/web/src/styles/controls.css +++ b/web/src/styles/controls.css @@ -14,7 +14,7 @@ .dd-btn{max-width:360px;gap:8px;justify-content:space-between} .dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dd-btn .car{font-size:10px;opacity:.7} -.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow-popover);padding:8px;display:flex;flex-direction:column;gap:6px} +.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;padding:8px;display:flex;flex-direction:column;gap:6px} .dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box} .dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none} .dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column} @@ -30,11 +30,6 @@ .dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0} /* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */ .dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px} -.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px} -.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none} -.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600} -.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0} -.menu .danger{color:var(--p-crit)} .confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px} .confirm .row{flex-wrap:nowrap} .confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0} diff --git a/web/src/ui/Dialog.tsx b/web/src/ui/Dialog.tsx new file mode 100644 index 0000000..6c09e57 --- /dev/null +++ b/web/src/ui/Dialog.tsx @@ -0,0 +1,15 @@ +import { useEffect, useRef, type ReactNode } from "react"; + +// окно: родной держит фокус и закрывает фон; Esc — onClose, закрытие по фону — на стороне использующего +export function Dialog({ label, onClose, size = "md", className, children }: { + label: string; onClose(): void; size?: "sm" | "md" | "lg"; className?: string; children: ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []); + return ( + { e.preventDefault(); onClose(); }}> + {children} + + ); +} diff --git a/web/src/board/components/Dropdown.test.tsx b/web/src/ui/Dropdown.test.tsx similarity index 94% rename from web/src/board/components/Dropdown.test.tsx rename to web/src/ui/Dropdown.test.tsx index 8be5b6f..d2bf479 100644 --- a/web/src/board/components/Dropdown.test.tsx +++ b/web/src/ui/Dropdown.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { StrictMode } from "react"; import { describe, expect, it, vi } from "vitest"; -import { Dropdown } from "./Dropdown.tsx"; +import { Dropdown } from "./Menu.tsx"; describe("Dropdown (T12.1)", () => { it("onOpen вызывается один раз на открытие, в том числе в StrictMode", () => { diff --git a/web/src/ui/Field.tsx b/web/src/ui/Field.tsx new file mode 100644 index 0000000..49dfc23 --- /dev/null +++ b/web/src/ui/Field.tsx @@ -0,0 +1,15 @@ +import { cloneElement, useId, type ReactElement, type ReactNode } from "react"; + +// подпись над контролом; связь через htmlFor/id, подсказка и ошибка — через aria-describedby (имя поля — только подпись) +export function Field({ label, hint, error, children }: { label: string; hint?: ReactNode; error?: ReactNode; children: ReactElement> }) { + const id = useId(); + const described = [hint && `${id}-hint`, error && `${id}-err`].filter(Boolean).join(" ") || undefined; + return ( +
+ + {cloneElement(children, { id, "aria-describedby": described, ...(error ? { "aria-invalid": "true" } : {}) })} + {hint && {hint}} + {error && {error}} +
+ ); +} diff --git a/web/src/board/components/Dropdown.tsx b/web/src/ui/Menu.tsx similarity index 53% rename from web/src/board/components/Dropdown.tsx rename to web/src/ui/Menu.tsx index f7eca59..f3b3d19 100644 --- a/web/src/board/components/Dropdown.tsx +++ b/web/src/ui/Menu.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { IconButton } from "./Button.tsx"; +import { IconMore } from "./icons.tsx"; // выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904) export function Dropdown({ button, className = "", children, onOpen }: { @@ -21,3 +23,27 @@ export function Dropdown({ button, className = "", children, onOpen }: { ); } + +// меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей) +export function Menu({ label, children, align = "end" }: { + label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; +}) { + return ( + } aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}> + {children} + + ); +} + +export function MenuItem({ onSelect, pressed, danger, icon, children }: { + onSelect(): void; pressed?: boolean; danger?: boolean; icon?: ReactNode; children: ReactNode; +}) { + return ( + + ); +} + +export function MenuSeparator() { return
; } diff --git a/web/src/ui/forms.test.tsx b/web/src/ui/forms.test.tsx new file mode 100644 index 0000000..8a48b7a --- /dev/null +++ b/web/src/ui/forms.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { Checkbox, Dialog, Field, Menu, MenuItem, MenuSeparator, SearchInput, Select, TextInput, Textarea } from "./index.ts"; + +describe("поля", () => { + it("Field связывает подпись с полем, показывает подсказку и ошибку", () => { + render(); + expect(screen.getByLabelText("Метка")).toHaveClass("ui-input"); + expect(screen.getByText("подсказка")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("плохо"); + }); + it("Textarea и Checkbox", () => { + render(<>