feat(web): поля, выпадающие списки, окно и меню дизайн-системы (#65)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 00:09:46 +04:00
parent 13e7ae35bc
commit d6ddca0751
11 changed files with 261 additions and 9 deletions

View File

@ -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 }: {

View File

@ -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;

View File

@ -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}

15
web/src/ui/Dialog.tsx Normal file
View File

@ -0,0 +1,15 @@
import { useEffect, useRef, type ReactNode } from "react";
// окно: родной <dialog> держит фокус и закрывает фон; 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<HTMLDialogElement>(null);
useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []);
return (
<dialog ref={ref} className={["ui-dialog", `ui-dialog--${size}`, className].filter(Boolean).join(" ")} aria-label={label}
onCancel={(e) => { e.preventDefault(); onClose(); }}>
{children}
</dialog>
);
}

View File

@ -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", () => {

15
web/src/ui/Field.tsx Normal file
View File

@ -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<Record<string, unknown>> }) {
const id = useId();
const described = [hint && `${id}-hint`, error && `${id}-err`].filter(Boolean).join(" ") || undefined;
return (
<div className="ui-field">
<label className="ui-field__label" htmlFor={id}>{label}</label>
{cloneElement(children, { id, "aria-describedby": described, ...(error ? { "aria-invalid": "true" } : {}) })}
{hint && <span className="ui-field__hint" id={`${id}-hint`}>{hint}</span>}
{error && <span className="ui-field__error" id={`${id}-err`} role="alert">{error}</span>}
</div>
);
}

View File

@ -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 }: {
</div>
);
}
// меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей)
export function Menu({ label, children, align = "end" }: {
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end";
}) {
return (
<Dropdown className={"more" + (align === "start" ? " dd--start" : "")}
button={({ open, toggle }) => <IconButton label={label} icon={<IconMore />} aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}>
{children}
</Dropdown>
);
}
export function MenuItem({ onSelect, pressed, danger, icon, children }: {
onSelect(): void; pressed?: boolean; danger?: boolean; icon?: ReactNode; children: ReactNode;
}) {
return (
<button type="button" className={danger ? "danger" : undefined} aria-pressed={pressed === undefined ? undefined : pressed} onClick={onSelect}>
{icon}{children}
</button>
);
}
export function MenuSeparator() { return <hr />; }

83
web/src/ui/forms.test.tsx Normal file
View File

@ -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(<Field label="Метка" hint="подсказка" error="плохо"><TextInput /></Field>);
expect(screen.getByLabelText("Метка")).toHaveClass("ui-input");
expect(screen.getByText("подсказка")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("плохо");
});
it("Textarea и Checkbox", () => {
render(<><Field label="Текст"><Textarea /></Field><Checkbox label="Скрыть" /></>);
expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA");
expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument();
});
it("SearchInput: ввод и «Очистить» только при значении", async () => {
const onChange = vi.fn();
const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />);
expect(screen.queryByRole("button", { name: "Очистить" })).toBeNull();
await userEvent.type(screen.getByRole("searchbox", { name: "Поиск" }), "a");
expect(onChange).toHaveBeenCalledWith("a");
rerender(<SearchInput value="abc" onChange={onChange} label="Поиск" />);
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
expect(onChange).toHaveBeenLastCalledWith("");
});
it("Select: combobox по имени и выбор", async () => {
const onChange = vi.fn();
render(<Field label="Статус"><Select onChange={(e) => onChange(e.target.value)}><option value="a">A</option><option value="b">B</option></Select></Field>);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Статус" }), "b");
expect(onChange).toHaveBeenCalledWith("b");
});
});
describe("Dialog", () => {
it("открыт, Esc вызывает onClose", () => {
const onClose = vi.fn();
render(<Dialog label="Окно" onClose={onClose}><p>тело</p></Dialog>);
const d = screen.getByRole("dialog", { name: "Окно" });
expect(d).toHaveAttribute("open");
expect(d).toHaveClass("ui-dialog--md");
fireEvent(d, new Event("cancel", { cancelable: true }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
function Demo({ onSelect }: { onSelect(): void }) {
const [on, setOn] = useState(true);
return (
<div>
<Menu label="Ещё">{(close) => (<>
<MenuItem pressed={on} onSelect={() => setOn(!on)}>Переключатель</MenuItem>
<MenuSeparator />
<MenuItem onSelect={() => { onSelect(); close(); }}>Действие</MenuItem>
</>)}</Menu>
<p>вне</p>
</div>
);
}
describe("Menu", () => {
it("открывает пункты, aria-pressed, выбор закрывает меню", async () => {
const onSelect = vi.fn();
render(<Demo onSelect={onSelect} />);
expect(screen.queryByText("Действие")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
expect(screen.getByRole("button", { name: "Переключатель" })).toHaveAttribute("aria-pressed", "true");
await userEvent.click(screen.getByText("Действие"));
expect(onSelect).toHaveBeenCalled();
expect(screen.queryByText("Действие")).toBeNull();
});
it("Escape и щелчок вне закрывают", async () => {
render(<Demo onSelect={() => {}} />);
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
await userEvent.keyboard("{Escape}");
expect(screen.queryByText("Действие")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
await userEvent.click(screen.getByText("вне"));
expect(screen.queryByText("Действие")).toBeNull();
});
});

View File

@ -8,3 +8,7 @@ export { Badge, Chip } from "./Badge.tsx";
export { Notice } from "./Notice.tsx";
export { Panel } from "./Panel.tsx";
export { PageHeader } from "./PageHeader.tsx";
export { Field } from "./Field.tsx";
export { TextInput, Textarea, SearchInput, Select, Checkbox } from "./inputs.tsx";
export { Dialog } from "./Dialog.tsx";
export { Dropdown, Menu, MenuItem, MenuSeparator } from "./Menu.tsx";

43
web/src/ui/inputs.tsx Normal file
View File

@ -0,0 +1,43 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
import { IconButton } from "./Button.tsx";
import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx";
type Size = { size?: "md" | "lg" };
const cx = (...a: Array<string | false | undefined>) => a.filter(Boolean).join(" ");
export function TextInput({ size = "md", className, ...rest }: Size & Omit<InputHTMLAttributes<HTMLInputElement>, "size">) {
return <input {...rest} className={cx("ui-input", `ui-input--${size}`, className)} />;
}
export function Textarea({ size = "md", className, ...rest }: Size & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size">) {
return <textarea {...rest} className={cx("ui-input", "ui-textarea", `ui-input--${size}`, className)} />;
}
export function SearchInput({ value, onChange, label, placeholder }: { value: string; onChange(v: string): void; label: string; placeholder?: string }) {
return (
<span className="ui-search">
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder}
value={value} onChange={(e) => onChange(e.target.value)} />
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => onChange("")} />}
</span>
);
}
export function Select({ size = "md", className, children, ...rest }: Size & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">) {
return (
<span className={cx("ui-select", `ui-select--${size}`, className)}>
<select {...rest} className="ui-input ui-select__control">{children}</select>
<span className="ui-select__chevron" aria-hidden="true"><IconChevronDown /></span>
</span>
);
}
export function Checkbox({ label, className, ...rest }: { label: ReactNode } & Omit<InputHTMLAttributes<HTMLInputElement>, "type">) {
return (
<label className={cx("ui-check", className)}>
<input {...rest} type="checkbox" className="ui-check__box" />
<span>{label}</span>
</label>
);
}

View File

@ -118,3 +118,74 @@
.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); }
/* поля */
.ui-field { display: flex; flex-direction: column; gap: var(--space-1); }
.ui-field__label { font-size: var(--fs-body); line-height: var(--lh-body); font-weight: 600; color: var(--color-text-primary); }
.ui-field__hint { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); }
.ui-field__error { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-status-error); }
.ui-input {
box-sizing: border-box; width: 100%; height: var(--control-h); padding: 0 var(--space-3);
border: 1px solid var(--color-border-default); border-radius: var(--control-radius);
background: var(--color-bg-surface); color: var(--color-text-primary);
font: inherit; font-family: var(--font-ui); font-size: var(--fs-body); line-height: var(--lh-body);
transition: border-color var(--motion), box-shadow var(--motion);
}
.ui-input--lg { height: var(--control-h-lg); }
.ui-input::placeholder { color: var(--color-text-placeholder); }
.ui-input:hover:not(:disabled) { border-color: var(--color-border-strong); }
.ui-input:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; border-color: var(--color-action-primary); }
.ui-input[aria-invalid="true"] { border-color: var(--color-status-error); }
.ui-input:disabled { cursor: not-allowed; color: var(--color-text-disabled); background: var(--color-bg-track); }
.ui-textarea { height: auto; min-height: 80px; padding: var(--space-2) var(--space-3); resize: vertical; }
.ui-search { position: relative; display: inline-flex; align-items: center; width: 100%; }
.ui-search__icon { position: absolute; left: var(--space-3); display: inline-flex; color: var(--color-text-secondary); pointer-events: none; }
.ui-search__input { padding-left: calc(var(--space-3) + 24px); padding-right: calc(var(--space-2) + var(--control-h)); }
.ui-search__input::-webkit-search-cancel-button { display: none; }
.ui-search__clear { position: absolute; right: 0; }
.ui-select { position: relative; display: inline-flex; align-items: center; width: 100%; }
.ui-select__control { appearance: none; padding-right: calc(var(--space-3) + 24px); cursor: pointer; }
.ui-select--lg .ui-select__control { height: var(--control-h-lg); }
.ui-select__chevron { position: absolute; right: var(--space-3); display: inline-flex; color: var(--color-text-secondary); pointer-events: none; }
.ui-check { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-text-primary); cursor: pointer; }
.ui-check__box { width: 16px; height: 16px; margin: 0; border-radius: var(--radius-xs); accent-color: var(--color-action-primary); cursor: pointer; }
.ui-check__box:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; }
/* окно */
.ui-dialog {
box-sizing: border-box; max-width: none; max-height: 90vh; padding: var(--space-6); overflow: auto;
border: 1px solid var(--color-border-default); border-radius: var(--radius-2xl);
background: var(--color-bg-surface); color: var(--color-text-primary); box-shadow: var(--shadow-dialog);
font-family: var(--font-ui);
}
.ui-dialog::backdrop { background: var(--color-overlay); }
.ui-dialog--sm { width: min(480px, calc(100vw - 32px)); }
.ui-dialog--md { width: min(640px, calc(100vw - 32px)); }
.ui-dialog--lg { width: min(1000px, calc(100vw - 32px)); }
@media (max-width: 700px) {
.ui-dialog { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
}
/* меню: пункты — кнопки; «выбрано» — цветом текста и галочкой */
.dd-menu {
background: var(--color-bg-surface); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg);
box-shadow: var(--shadow-popover); color: var(--color-text-primary); font-family: var(--font-ui);
}
.dd--start > .dd-menu.menu { left: 0; right: auto; }
.menu button {
display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%;
min-height: var(--control-h); padding: var(--space-1) var(--space-3); border: 0; border-radius: var(--radius-sm);
background: none; color: var(--color-text-primary); font: inherit; font-family: var(--font-ui);
font-size: var(--fs-body); line-height: var(--lh-body); text-align: left; cursor: pointer;
transition: background var(--motion), color var(--motion);
}
.menu button:hover { background: var(--color-bg-hover); }
.menu button:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; background: var(--color-bg-hover); }
.menu button[aria-pressed="true"] { color: var(--color-action-primary); }
.menu button[aria-pressed="true"]::after { content: "✓"; font-weight: 600; }
.menu button.danger { color: var(--color-status-error); }
.menu hr { border: 0; border-top: 1px solid var(--color-border-default); margin: var(--space-1) 0; }