style(web): вход, новый проект, служебные экраны; старые стили убраны (#65)
Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки переведены на компоненты системы. controls.css удалён, псевдонимы токенов убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски, тест на ошибку списка, стабилизирован тест проектов. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
047835b223
commit
2a472cacbf
@ -30,6 +30,11 @@
|
||||
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
|
||||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||||
и `storage.ts` (ключи `localStorage` прежней страницы; у проекта AWPCA — прежние, у остальных с суффиксом `@<метка>`; набор ключей — состояние модуля, выставляется при монтировании страницы), отрисовка — `web/src/board/components`.
|
||||
Вид — дизайн-система AURA: токены цвета, шрифта, отступов и теней (светлая и тёмная темы) — `web/src/styles/tokens.css`,
|
||||
единственное место с литералами цвета; базовые компоненты (`Button`, `Notice`, `Dialog`, `Field`, `Panel`, меню и др.) —
|
||||
`web/src/ui/`, остальные стили подключаются к токенам по семантическим именам. Обновление данных из Jira (кнопка в
|
||||
верхней панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только
|
||||
источник и события снимка.
|
||||
|
||||
Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
@ -36,8 +36,9 @@ describe("оболочка", () => {
|
||||
it("неизвестный путь — «не найдено» со ссылками", async () => {
|
||||
render(<App router={at("/app/nope")} />);
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board");
|
||||
expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues");
|
||||
const main = within(screen.getByRole("main"));
|
||||
expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board");
|
||||
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues");
|
||||
});
|
||||
|
||||
it("«Сменить токен» показывает экран входа", async () => {
|
||||
@ -57,6 +58,7 @@ describe("оболочка", () => {
|
||||
render(<App router={router} />);
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument();
|
||||
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
|
||||
});
|
||||
|
||||
@ -7,7 +7,6 @@ import { RefreshProvider } from "./shell/RefreshProvider.tsx";
|
||||
import { routes } from "./routes.tsx";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/controls.css";
|
||||
|
||||
type Router = ReturnType<typeof createBrowserRouter>;
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ describe("вход", () => {
|
||||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Клиентский токен хаба")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
it("верный токен сохраняется и открывает приложение", async () => {
|
||||
@ -37,7 +38,7 @@ describe("вход", () => {
|
||||
const input = screen.getByLabelText("Клиентский токен хаба");
|
||||
await userEvent.type(input, "wrong");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Токен не подошёл/);
|
||||
expect(input).toHaveValue("wrong");
|
||||
expect(localStorage.getItem("jira-hub-token")).toBeNull();
|
||||
});
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ApiError, AuthError, checkToken } from "../api/client.ts";
|
||||
import { Button, Field, Notice, Panel, TextInput } from "../ui/index.ts";
|
||||
import { setToken, TOKEN_RE } from "./token.ts";
|
||||
import { useAuth } from "./useAuth.ts";
|
||||
import "./login.css";
|
||||
|
||||
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
|
||||
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
|
||||
@ -36,15 +38,19 @@ export function LoginScreen() {
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Вход в хаб</h2>
|
||||
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
|
||||
<Panel padding="lg" className="login__panel">
|
||||
<h2 className="login__title">Вход в хаб</h2>
|
||||
<p className="login__text">Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
|
||||
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
|
||||
{error && <p className="err" role="alert">{error}</p>}
|
||||
<form onSubmit={submit}>
|
||||
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
|
||||
{error && <Notice tone="error">{error}</Notice>}
|
||||
<form className="login__form" onSubmit={submit}>
|
||||
<Field label="Клиентский токен хаба">
|
||||
<TextInput size="lg" type="password" autoComplete="off" spellCheck={false}
|
||||
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<button className="btn" type="submit" disabled={busy}>Войти</button>
|
||||
</Field>
|
||||
<Button variant="primary" size="lg" type="submit" loading={busy}>Войти</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
web/src/auth/login.css
Normal file
9
web/src/auth/login.css
Normal file
@ -0,0 +1,9 @@
|
||||
/* экран входа: Panel 480 px по центру канвы (design.md, решение 20) */
|
||||
.login { display: flex; justify-content: center; padding: 64px var(--space-4) var(--space-8); }
|
||||
.login__panel { box-sizing: border-box; width: min(480px, 100%); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.login__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); }
|
||||
.login__text { margin: 0; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-text-secondary); }
|
||||
.login__text code { font-family: var(--font-mono); font-size: var(--fs-caption); color: var(--color-text-primary); }
|
||||
.login__form { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.login__form .ui-btn { align-self: stretch; }
|
||||
@media (max-width: 639px) { .login { padding-top: var(--space-6); } }
|
||||
@ -49,17 +49,9 @@ svg.guides{z-index:3}
|
||||
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.empty{padding:40px 16px;color:var(--color-text-secondary);text-align:center}
|
||||
#hubState{white-space:normal;min-width:0}
|
||||
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-xl)}
|
||||
.login h2{margin:0 0 8px;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700}
|
||||
.login p{margin:0 0 12px;font-size:var(--fs-body);color:var(--color-text-secondary)}
|
||||
.login p.err{color:var(--color-status-error)}
|
||||
.login code{font-family:var(--font-mono);font-size:13px;color:var(--color-text-primary)}
|
||||
.login form{display:flex;gap:8px;margin:0 0 12px}
|
||||
.login input{flex:1;min-width:0;font:inherit;font-size:var(--fs-body);height:var(--control-h);box-sizing:border-box;padding:0 10px;color:var(--color-text-primary);background:var(--color-bg-canvas);border:1px solid var(--color-border-default);border-radius:var(--radius-sm)}
|
||||
.login input:focus-visible{border-color:var(--color-border-selected);outline:none}
|
||||
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--color-border-default)}
|
||||
.lane:nth-child(even of .lane){background:var(--lane-alt)}
|
||||
.lane.folded{background:var(--color-bg-hover)}
|
||||
.lane.folded{background:var(--color-bg-track);border-bottom:1px solid var(--color-border-strong);border-top-color:var(--color-border-strong)}
|
||||
.lane.target{box-shadow:inset 0 3px 0 var(--color-action-primary)}
|
||||
.lane.target-after{box-shadow:inset 0 -3px 0 var(--color-action-primary)}
|
||||
.lane.dragging{opacity:.6}
|
||||
@ -78,7 +70,6 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.lane-tools button{display:inline-flex;align-items:center;justify-content:center;color:var(--color-text-secondary);background:var(--color-bg-surface);border:1px solid var(--color-border-strong);border-radius:var(--radius-xs);width:22px;height:22px;cursor:pointer;padding:0}
|
||||
.lane-tools button:hover{border-color:var(--color-action-primary);color:var(--color-action-primary)}
|
||||
.lane-tools button:disabled{opacity:.35;cursor:default}
|
||||
.lane-tools .up svg{transform:rotate(180deg)}
|
||||
/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */
|
||||
.dcol{position:absolute;top:0;border-left:1px solid var(--color-border-default);box-sizing:border-box;pointer-events:none}
|
||||
.dcol:first-of-type{border-left:0}
|
||||
@ -128,7 +119,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--color-border-strong) 50%);border-bottom-right-radius:11px;z-index:2}
|
||||
.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--color-action-primary) 50%)}
|
||||
.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
|
||||
.key{font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--color-action-primary);text-decoration:none;flex:0 0 auto}
|
||||
.card .key{font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--color-action-primary);text-decoration:none;flex:0 0 auto}
|
||||
.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:var(--radius-round);background:var(--color-bg-track);color:var(--color-text-secondary);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
|
||||
.sprint-tag.sel{background:var(--color-action-primary);color:var(--color-text-on-primary)}
|
||||
.sprint-tag.none{background:transparent;border:1px dashed var(--color-border-strong)}
|
||||
@ -163,6 +154,8 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
|
||||
/* 8. Экспорт JSON и таблица связей (включаются из меню «Ещё») */
|
||||
.export{display:flex;flex-direction:column;gap:var(--space-2);margin:0 0 var(--space-2)}
|
||||
.export__row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2)}
|
||||
.export__hint{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--color-border-default);border-radius:var(--radius-sm);background:var(--color-bg-surface);color:var(--color-text-primary);padding:8px;resize:vertical}
|
||||
.link-table{margin-top:var(--space-3)}
|
||||
.tbl{max-width:100%;overflow-x:auto;margin-top:var(--space-2)}
|
||||
@ -173,7 +166,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap;color:var(--
|
||||
|
||||
/* 9. Движение и узкие экраны */
|
||||
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
|
||||
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
|
||||
@media (prefers-reduced-motion:reduce){.card .grip,.fold svg{transition:none}}
|
||||
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
|
||||
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}}
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@ export function ExportBox({ text, onClose }: { text: string; onClose(): void })
|
||||
let ta: HTMLTextAreaElement | null = null;
|
||||
return (
|
||||
<Panel className="export">
|
||||
<div className="row">
|
||||
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.</span>
|
||||
<div className="export__row">
|
||||
<span className="export__hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.</span>
|
||||
<Button onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</Button>
|
||||
<Button onClick={onClose}>Закрыть</Button>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import { IconChevronDown } from "../../ui/icons.tsx";
|
||||
import { IconChevronDown, IconChevronUp } from "../../ui/icons.tsx";
|
||||
import type { LaneGeom } from "../layout/layout.ts";
|
||||
import { plural } from "../model/util.ts";
|
||||
|
||||
@ -44,7 +44,7 @@ export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onTo
|
||||
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
|
||||
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
|
||||
<span className="lane-tools">
|
||||
<button type="button" className="up" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}><IconChevronDown size={16} /></button>
|
||||
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}><IconChevronUp size={16} /></button>
|
||||
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}><IconChevronDown size={16} /></button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -19,7 +19,7 @@ export function IssueFilters({ filters, set, onReset, q, setQ, showClosed, opts,
|
||||
q: string; setQ(v: string): void; showClosed: boolean; opts: Record<Name, Opt[]>; hasBoard: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(readOpen);
|
||||
const toggle = () => setOpen((o) => { saveOpen(!o); return !o; });
|
||||
const toggle = () => { saveOpen(!open); setOpen(!open); }; // T8.1: запись в хранилище вне функции-обновителя
|
||||
const labelOf = (name: Name, value: string) => opts[name].find((o) => o.value === value)?.label ?? value;
|
||||
const chosen = SELECTS.filter(([n]) => filters[n]);
|
||||
return (
|
||||
|
||||
@ -51,6 +51,25 @@ describe("список задач", () => {
|
||||
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("assignee")).toBe("Петров Пётр Петрович"));
|
||||
});
|
||||
|
||||
it("ошибка списка — сообщение хаба и «Повторить», повтор перезапрашивает список (T8.4)", async () => {
|
||||
let fail = true;
|
||||
const fetch = mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/board") return json(200, SNAP);
|
||||
if (url.pathname === "/issues") return fail ? json(500, { error: "boom" }) : json(200, { issues: [row(1)] });
|
||||
return json(404, {});
|
||||
});
|
||||
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("хаб ответил 500");
|
||||
expect(screen.queryByText("По этим фильтрам задач нет")).toBeNull();
|
||||
fail = false;
|
||||
await userEvent.click(within(alert).getByRole("button", { name: "Повторить" }));
|
||||
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(issuesCalls(fetch)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("«Показать ещё» до неполной порции", async () => {
|
||||
const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51))));
|
||||
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
.issue--state{padding-top:var(--space-6)}
|
||||
.issue-head{position:sticky;top:0;z-index:1;background:var(--color-bg-surface);padding:var(--space-6) 0 var(--space-4);border-bottom:1px solid var(--color-border-default);margin-bottom:var(--space-5)}
|
||||
.issue-head .row1{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}
|
||||
.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary)}
|
||||
.issue-head .key{font-family:var(--font-mono);font-size:13px;font-weight:400;color:var(--color-text-secondary)}
|
||||
.issue-head .spacer{flex:1 1 auto}
|
||||
.issue-head h2{margin:var(--space-3) 0 var(--space-1);font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;overflow-wrap:anywhere}
|
||||
.issue-head .facts{margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:0 var(--space-1);color:var(--color-text-secondary)}
|
||||
@ -85,8 +85,8 @@
|
||||
.issue .comments>ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
|
||||
.issue .comments>ul>li{padding:var(--space-3) 0;border-top:1px solid var(--color-border-default)}
|
||||
.issue .comments>ul>li:first-child{border-top:0;padding-top:0}
|
||||
.issue .comments .c-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-1);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.issue .comments .c-head .ui-btn{margin-block:-8px;padding:0 var(--space-2);margin-right:calc(-1*var(--space-2));font-size:var(--fs-caption)}
|
||||
.issue .comments .c-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.issue .comments .c-head .ui-btn{padding:0 var(--space-2);margin-right:calc(-1*var(--space-2));font-size:var(--fs-caption)}
|
||||
.issue .comments pre.body{white-space:pre-wrap;font:inherit;margin:0;overflow-wrap:anywhere}
|
||||
.issue .fields{display:grid;grid-template-columns:1fr;gap:var(--space-3);margin:0}
|
||||
.issue .fields dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
@ -102,12 +102,12 @@
|
||||
/* служебные заметки (design 20) */
|
||||
.note{display:flex;flex-direction:column;gap:var(--space-3)}
|
||||
.note fieldset{display:flex;flex-direction:column;gap:var(--space-3);border:0;margin:0;padding:0;min-width:0}
|
||||
.note .row{gap:var(--space-2)}
|
||||
.note .row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2)}
|
||||
.note .note-confirm{display:flex;flex-direction:column;gap:var(--space-2);padding:var(--space-3);font-size:var(--fs-body);border:1px solid var(--color-border-strong);border-radius:var(--radius-lg);background:var(--color-bg-surface)}
|
||||
.note.view .hint{margin-top:var(--space-3);font-size:var(--fs-caption);line-height:var(--lh-caption)}
|
||||
|
||||
/* окно карточки задачи (design 13): размер и рамка — Dialog size="lg"; прокрутка и шапка — здесь */
|
||||
.issue-modal{padding:0}
|
||||
.ui-dialog.issue-modal{padding:0}
|
||||
.issue-modal > .issue{padding:0 var(--space-6) var(--space-6)}
|
||||
@media (max-width:700px){
|
||||
.issue-modal > .issue{padding:0 var(--space-4) var(--space-4)}
|
||||
|
||||
@ -10,13 +10,13 @@ div.wiki :is(h5,h6){font-size:13px}
|
||||
div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px}
|
||||
div.wiki li>:is(ul,ol){margin:2px 0 0}
|
||||
div.wiki table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px}
|
||||
div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word}
|
||||
div.wiki :is(th,td){border:1px solid var(--color-border-default);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word}
|
||||
/* глобальные table/th из board.css (min-width 520px, прописные 11px) здесь не действуют (I-1) */
|
||||
div.wiki th{background:var(--soft);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit}
|
||||
div.wiki th{background:var(--color-bg-hover);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit}
|
||||
div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px}
|
||||
div.wiki code{background:var(--soft);border-radius:3px;padding:0 3px}
|
||||
div.wiki pre{background:var(--soft);border-radius:4px;padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal}
|
||||
div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--line);color:var(--muted)}
|
||||
div.wiki hr{border:0;border-top:1px solid var(--line);margin:8px 0}
|
||||
div.wiki .mention{color:var(--accent)}
|
||||
div.wiki code{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-xs);padding:0 3px}
|
||||
div.wiki pre{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-sm);padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal}
|
||||
div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--color-border-default);color:var(--color-text-secondary)}
|
||||
div.wiki hr{border:0;border-top:1px solid var(--color-border-default);margin:8px 0}
|
||||
div.wiki .mention{color:var(--color-action-primary)}
|
||||
pre.wiki-source{white-space:pre-wrap;font:inherit;margin:0}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useCreateProject } from "../api/queries.ts";
|
||||
import type { Project } from "../api/types.ts";
|
||||
import { Button, Dialog, Field, Notice, TextInput } from "../ui/index.ts";
|
||||
import "./projects.css";
|
||||
|
||||
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать»
|
||||
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками
|
||||
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState("");
|
||||
const create = useCreateProject();
|
||||
useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -21,21 +20,20 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
|
||||
const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null;
|
||||
|
||||
return (
|
||||
<dialog ref={ref} className="project-dialog" aria-label="Новый проект"
|
||||
onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>
|
||||
<form onSubmit={submit}>
|
||||
<h2>Новый проект</h2>
|
||||
<label>Метка<input value={label} onChange={(e) => setLabel(e.target.value)} required /></label>
|
||||
<label>Название<input value={name} onChange={(e) => setName(e.target.value)} required /></label>
|
||||
<label>Номер доски Jira
|
||||
<input type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button className="btn" type="submit" disabled={create.isPending}>Создать</button>
|
||||
<button className="btn" type="button" onClick={onClose} disabled={create.isPending}>Отмена</button>
|
||||
{text && <span className="warn" role="alert">{text}</span>}
|
||||
<Dialog size="sm" label="Новый проект" className="project-dialog" onClose={() => { if (!create.isPending) onClose(); }}>
|
||||
<form className="project-form" onSubmit={submit}>
|
||||
<h2 className="project-form__title">Новый проект</h2>
|
||||
<Field label="Метка"><TextInput value={label} onChange={(e) => setLabel(e.target.value)} required /></Field>
|
||||
<Field label="Название"><TextInput value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
<Field label="Номер доски Jira">
|
||||
<TextInput type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
|
||||
</Field>
|
||||
{text && <Notice tone="error">{text}</Notice>}
|
||||
<div className="project-form__actions">
|
||||
<Button variant="secondary" disabled={create.isPending} onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" type="submit" loading={create.isPending}>Создать</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Project } from "../api/types.ts";
|
||||
import { Button, Notice } from "../ui/index.ts";
|
||||
import { useProjectContext } from "./ProjectContext.ts";
|
||||
import "./projects.css";
|
||||
|
||||
// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать
|
||||
export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) {
|
||||
const { state, openNew } = useProjectContext();
|
||||
switch (state.kind) {
|
||||
case "loading": return <p className="hint">Загрузка проектов…</p>;
|
||||
case "error": return <p className="warn" role="alert">Проекты не загрузились: {state.error.message}</p>;
|
||||
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
|
||||
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>;
|
||||
case "unknown":
|
||||
return <div className="project-gate" data-testid="project-unknown"><p className="warn">проект {state.label} не найден</p><p className="hint">Выберите проект в списке «Проект» вверху.</p></div>;
|
||||
return <div className="project-gate" data-testid="project-unknown">
|
||||
<Notice tone="warning" title={`проект ${state.label} не найден`}>Выберите проект в списке «Проект» вверху.</Notice></div>;
|
||||
case "none":
|
||||
return <div className="project-gate"><p className="hint">Проектов нет.</p>
|
||||
<button className="btn" type="button" onClick={openNew}>+ Новый проект…</button></div>;
|
||||
return <div className="project-gate">
|
||||
<Notice tone="info" title="Проектов нет." action={<Button variant="primary" onClick={openNew}>+ Новый проект…</Button>} /></div>;
|
||||
case "ok": return <>{children(state.project)}</>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
/* выбор проекта и окно «Новый проект» */
|
||||
.appbar .project-picker{max-width:280px;min-width:160px}
|
||||
.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0}
|
||||
.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px}
|
||||
.project-dialog::backdrop{background:var(--color-overlay)}
|
||||
.project-dialog form{display:flex;flex-direction:column;gap:10px}
|
||||
.project-dialog h2{margin:0 0 4px;font-size:18px}
|
||||
.project-dialog label{display:flex;flex-direction:column;gap:2px;font-size:13px;color:var(--muted)}
|
||||
.project-dialog input{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px}
|
||||
.project-dialog .actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
/* выбор проекта, служебные состояния выбора и окно «Новый проект» */
|
||||
.appbar .project-picker { max-width: 280px; min-width: 160px; }
|
||||
.project-gate { max-width: 640px; margin: var(--space-6) 0; }
|
||||
.project-gate .ui-notice { align-items: center; }
|
||||
.project-form { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.project-form__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); }
|
||||
.project-form__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
||||
|
||||
@ -83,6 +83,7 @@ describe("проект в приложении", () => {
|
||||
const h = hub();
|
||||
open("/app/issues?project=NOPE");
|
||||
expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху.");
|
||||
expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument();
|
||||
expect(h.get("/issues")).toHaveLength(0);
|
||||
});
|
||||
@ -161,7 +162,7 @@ describe("проект в приложении", () => {
|
||||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||||
expect(h.get("/projects").length).toBeGreaterThan(1);
|
||||
expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK")); // O-4: адрес меняется раньше отрисовки
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@ -52,6 +52,19 @@ describe("верхняя панель", () => {
|
||||
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => {
|
||||
hub();
|
||||
open("/app/nowhere?project=AWPCA");
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Раздел" });
|
||||
expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
|
||||
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
|
||||
const main = within(screen.getByRole("main"));
|
||||
expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board");
|
||||
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена");
|
||||
});
|
||||
|
||||
it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => {
|
||||
let done = false;
|
||||
const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) });
|
||||
|
||||
@ -7,8 +7,8 @@ import { DataStatus } from "./DataStatus.tsx";
|
||||
|
||||
export type Section = "/board" | "/issues";
|
||||
|
||||
// верхняя панель (design.md, решение 9): название, проект, разделы, состояние данных, тема, меню «Ещё»
|
||||
export function AppBar({ section, project }: { section: Section; project: string | null }) {
|
||||
// верхняя панель (design.md, решение 9); section = null на путях вне разделов — активной ссылки нет (T5.1): название, проект, разделы, состояние данных, тема, меню «Ещё»
|
||||
export function AppBar({ section, project }: { section: Section | null; project: string | null }) {
|
||||
const qc = useQueryClient();
|
||||
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
||||
return (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Button, Notice } from "../ui/index.ts";
|
||||
|
||||
interface State { error: Error | null }
|
||||
|
||||
@ -10,10 +11,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div className="crash" role="alert">
|
||||
<h2>Страница сломалась</h2>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button className="btn" type="button" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
<div className="crash">
|
||||
<Notice tone="error" title={<h2>Страница сломалась</h2>}
|
||||
action={<Button variant="primary" onClick={() => window.location.reload()}>Перезагрузить</Button>}>
|
||||
{this.state.error.message}
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -70,8 +70,8 @@ export function Layout() {
|
||||
}, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]);
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const section = at === "/issues" ? "/issues" : "/board";
|
||||
const select = useCallback((label: string) => { void navigate({ pathname: section, search: withProject("", label) }); }, [navigate, section]);
|
||||
const section = at === "/issues" || at === "/board" ? at : null;
|
||||
const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]);
|
||||
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
|
||||
[state, projects.data, select]);
|
||||
|
||||
@ -79,7 +79,7 @@ export function Layout() {
|
||||
return (
|
||||
<ProjectContext value={ctx}>
|
||||
<AppBar section={section} project={okLabel} />
|
||||
<main className={"page" + (section === "/board" ? " page--board" : "")}>
|
||||
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
|
||||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||||
</main>
|
||||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Link } from "react-router";
|
||||
import { Notice } from "../ui/index.ts";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="crash">
|
||||
<h2>Страница не найдена</h2>
|
||||
<p>Разделы приложения: <Link to="/board">доска</Link>, <Link to="/issues">задачи</Link>.</p>
|
||||
<Notice tone="warning" title={<h2>Страница не найдена</h2>}>
|
||||
Разделы приложения: <Link to="/board">Доска связей</Link>, <Link to="/issues">Задачи</Link>.
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
.data-status__event--warning{color:var(--color-status-warning)}
|
||||
.data-status__event--error{color:var(--color-status-error)}
|
||||
.crash{max-width:640px;margin:48px auto}
|
||||
.crash h2{margin:0;font:inherit}
|
||||
@media (max-width:1099px){
|
||||
.appbar .data-status{order:10;flex:1 0 100%}
|
||||
.appbar .appbar__spacer{flex:1 1 0}
|
||||
|
||||
@ -3,13 +3,8 @@ html,body{min-height:100%}
|
||||
body{font:var(--fs-body)/var(--lh-body) var(--font-ui);background:var(--color-bg-canvas);color:var(--color-text-primary);margin:0}
|
||||
h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;margin:0}
|
||||
[hidden]{display:none!important}
|
||||
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.spacer{flex:1 1 auto}
|
||||
.hint{font-size:13px;color:var(--muted)}
|
||||
.warn{color:var(--p-high)}
|
||||
.page{padding-inline:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto}
|
||||
.page--board{max-width:none}
|
||||
@media (max-width:639px){.page{padding-inline:16px}}
|
||||
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
|
||||
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
|
||||
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
|
||||
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
|
||||
/* 4. Кнопки и выпадающие списки */
|
||||
.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
|
||||
.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
|
||||
.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
|
||||
.btn .n{font-weight:600;color:var(--p-crit)}
|
||||
.btn .n.zero{font-weight:500;color:var(--muted)}
|
||||
.btn[aria-pressed="true"] .n{color:inherit}
|
||||
.btn.sm{height:28px;padding:0 8px;font-size:13px}
|
||||
.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
|
||||
.dd{position:relative;display:inline-block}
|
||||
.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;padding:8px;display:flex;flex-direction:column;gap:6px}
|
||||
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
|
||||
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
|
||||
.dd-item:hover{background:var(--soft)}
|
||||
.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
|
||||
.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.dd-item.none{color:var(--muted);font-style:italic}
|
||||
.dd-item.zero{opacity:.5}
|
||||
.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
|
||||
.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
|
||||
.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
|
||||
.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}
|
||||
.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}
|
||||
.confirm .btn.yes{background:var(--p-crit);color:var(--color-text-on-primary);border-color:var(--p-crit)}
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
--p-low: #a3a39b;
|
||||
--edge: #a3a39b;
|
||||
--guide: #c2410c;
|
||||
--lane-alt: #f1f1ee;
|
||||
--lane-alt: #f5f5f2;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
|
||||
--space-5: 20px; --space-6: 24px; --space-7: 28px; --space-8: 32px;
|
||||
@ -55,10 +55,6 @@
|
||||
--lh-caption: 16px; --lh-body: 20px; --lh-lg: 24px; --lh-section: 28px;
|
||||
--control-h: 32px; --control-h-lg: 40px; --control-radius: 10px;
|
||||
--motion: 180ms ease;
|
||||
/* Псевдонимы на переход: удаляются в Task 10 */
|
||||
--bg: var(--color-bg-canvas); --fg: var(--color-text-primary); --muted: var(--color-text-secondary);
|
||||
--line: var(--color-border-default); --card: var(--color-bg-surface); --panel: var(--color-bg-surface);
|
||||
--soft: var(--color-bg-hover); --accent: var(--color-action-primary); --accent-fg: var(--color-text-on-primary);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
@ -104,7 +100,7 @@
|
||||
--p-low: #6c726b;
|
||||
--edge: #6c726b;
|
||||
--guide: #f08a5a;
|
||||
--lane-alt: #212121;
|
||||
--lane-alt: #252525;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
}
|
||||
}
|
||||
@ -151,6 +147,6 @@
|
||||
--p-low: #6c726b;
|
||||
--edge: #6c726b;
|
||||
--guide: #f08a5a;
|
||||
--lane-alt: #212121;
|
||||
--lane-alt: #252525;
|
||||
--edge-strong: var(--color-action-primary);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { Link } from "react-router";
|
||||
type Option<T extends string> = { value: T; label: string; href?: string };
|
||||
|
||||
export function Segmented<T extends string>({ label, value, options, onChange }: {
|
||||
label: string; value: T; options: Option<T>[]; onChange?(v: T): void;
|
||||
label: string; value: T | null; options: Option<T>[]; onChange?(v: T): void;
|
||||
}) {
|
||||
if (options.some((o) => o.href)) {
|
||||
return (
|
||||
|
||||
@ -15,6 +15,7 @@ export const IconUpload = make(<><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><pa
|
||||
export const IconMore = make(<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>);
|
||||
export const IconClose = make(<><path d="M6 6l12 12M18 6L6 18"/></>);
|
||||
export const IconChevronDown = make(<><path d="M6 9l6 6 6-6"/></>);
|
||||
export const IconChevronUp = make(<><path d="M6 15l6-6 6 6"/></>);
|
||||
export const IconSearch = make(<><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/></>);
|
||||
export const IconFilter = make(<><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>);
|
||||
export const IconSun = make(<><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></>);
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
.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 a { color: var(--color-action-primary); }
|
||||
.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); }
|
||||
@ -170,11 +171,28 @@
|
||||
.ui-dialog { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
|
||||
}
|
||||
|
||||
/* меню: пункты — кнопки; «выбрано» — цветом текста и галочкой */
|
||||
/* выпадающая панель и меню: пункты — кнопки; «выбрано» — цветом текста и галочкой */
|
||||
.dd { position: relative; display: inline-block; }
|
||||
.dd-menu {
|
||||
position: absolute; top: calc(100% + var(--space-1)); left: 0; z-index: 20; min-width: 280px; max-width: 420px;
|
||||
display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2);
|
||||
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-menu.menu { right: 0; left: auto; min-width: 230px; gap: 0; padding: var(--space-1); }
|
||||
.dd-list { max-height: 260px; overflow: auto; display: flex; flex-direction: column; }
|
||||
.dd-item {
|
||||
display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-body); line-height: var(--lh-body); cursor: pointer;
|
||||
}
|
||||
.dd-item:hover { background: var(--color-bg-hover); }
|
||||
.dd-item input { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--color-action-primary); cursor: pointer; }
|
||||
.dd-item input:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; }
|
||||
.dd-item .n { margin-left: auto; font-size: var(--fs-caption); color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.dd-item.none { color: var(--color-text-secondary); font-style: italic; }
|
||||
.dd-item.zero { opacity: .5; }
|
||||
.dd-empty { padding: var(--space-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); }
|
||||
.dd-foot { display: flex; gap: var(--space-2); justify-content: space-between; border-top: 1px solid var(--color-border-default); padding-top: var(--space-2); }
|
||||
.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%;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user