Редизайн веб-приложения по дизайн-системе AURA (#65) #66
@ -46,12 +46,12 @@ describe("карточка задачи", () => {
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument();
|
||||
const head = screen.getByTestId("issue-head");
|
||||
expect(within(head).getByText("AURORA-9001")).toBeInTheDocument();
|
||||
expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument();
|
||||
expect(within(head).getByText("В работе", { selector: ".ui-badge" })).toBeInTheDocument();
|
||||
expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed)
|
||||
expect(within(head).queryByText(/факт:/)).toBeNull();
|
||||
expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик");
|
||||
expect(within(head).getByText("просрочено")).toHaveClass("overdue");
|
||||
expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
|
||||
expect(within(head).getByText("просрочено")).toHaveClass("ui-badge--error");
|
||||
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
|
||||
});
|
||||
|
||||
it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => {
|
||||
|
||||
@ -8,6 +8,7 @@ import { fmtDate, fmtStamp } from "../board/model/util.ts";
|
||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { useCommentsHidden } from "./commentsPref.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../ui/index.ts";
|
||||
import { Notes } from "./Notes.tsx";
|
||||
import { dueOf } from "./options.ts";
|
||||
import { WikiText } from "./wiki/WikiText.tsx";
|
||||
@ -19,17 +20,23 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
const closed = useClosedStatuses();
|
||||
const close = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
|
||||
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
|
||||
const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
|
||||
if (issue.isPending) {
|
||||
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
|
||||
}
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
|
||||
return (
|
||||
<div className="issue issue--state" data-testid="issue-card">
|
||||
<Notice tone="info" action={<Button onClick={onClose}>Закрыть</Button>}>Задачи {key} нет в зеркале хаба.</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||||
<div className="issue issue--state" data-testid="issue-card">
|
||||
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button><Button onClick={onClose}>Закрыть</Button></>}>
|
||||
{issue.error.message}
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -37,7 +44,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
const due = dueOf(i);
|
||||
const facts = [
|
||||
i.assignee && <span key="who">{i.assignee}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <Badge tone="error">просрочено</Badge></>}</span>,
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
@ -46,16 +53,15 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
<header className="issue-head" data-testid="issue-head">
|
||||
<div className="row1">
|
||||
<span className="key">{i.key}</span>
|
||||
{i.status && <span className="badge status">{i.status}</span>}
|
||||
{i.status && <Badge>{i.status}</Badge>}
|
||||
<span className="spacer" />
|
||||
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
|
||||
<a className="ui-btn ui-btn--secondary ui-btn--md" href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener"><IconExternal size={16} />Открыть в Jira</a>
|
||||
{close}
|
||||
</div>
|
||||
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
|
||||
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
|
||||
{issue.isError && (
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
<Notice tone="error" action={<Button onClick={() => void issue.refetch()}>Повторить</Button>}>{issue.error.message}</Notice>
|
||||
)}
|
||||
</header>
|
||||
<div className="cols">
|
||||
@ -64,11 +70,11 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
|
||||
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
|
||||
</div>
|
||||
<div className="side">
|
||||
<Panel className="side">
|
||||
<Notes key={i.key} issue={i} />
|
||||
<OtherFields issue={i} />
|
||||
<Commands issue={i} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -90,7 +96,7 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
||||
<IssueLink issueKey={l.key} />{" "}
|
||||
{l.summary === null && l.status === null
|
||||
? <span className="sum absent">нет в зеркале</span>
|
||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
|
||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -102,7 +108,7 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
||||
|
||||
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
|
||||
function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) {
|
||||
return <button className="link" type="button" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</button>;
|
||||
return <Button variant="ghost" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</Button>;
|
||||
}
|
||||
|
||||
function Description({ text, projectKey }: { text: string; projectKey?: string }) {
|
||||
@ -134,7 +140,7 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
|
||||
{source
|
||||
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
|
||||
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} /></div>}
|
||||
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
|
||||
{(long || open) && <Button variant="ghost" className="more" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</Button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -143,8 +149,8 @@ function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
|
||||
const [source, setSource] = useState(false);
|
||||
return (
|
||||
<li>
|
||||
<div className="who">
|
||||
<span>{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</span>
|
||||
<div className="c-head">
|
||||
<span className="c-head__name">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</span>
|
||||
<SourceToggle source={source} onToggle={() => setSource(!source)} />
|
||||
</div>
|
||||
{source
|
||||
@ -160,7 +166,7 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s
|
||||
<section className="comments" data-testid="comments">
|
||||
<div className="sec-head">
|
||||
<h3>Комментарии ({items.length})</h3>
|
||||
{items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
|
||||
{items.length > 0 && <Button variant="ghost" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</Button>}
|
||||
</div>
|
||||
{!items.length && <p className="hint">комментариев нет</p>}
|
||||
{items.length > 0 && !hidden && (
|
||||
@ -194,13 +200,13 @@ function Commands({ issue: i }: { issue: IssueDetail }) {
|
||||
<section className="commands">
|
||||
<details>
|
||||
<summary>Команды моста ({i.commands.length})</summary>
|
||||
<table className="grid">
|
||||
<div className="cmd-wrap"><table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
|
||||
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,27 +1,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Dialog } from "../ui/index.ts";
|
||||
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
|
||||
import { IssueCard } from "./IssueCard.tsx";
|
||||
|
||||
// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи
|
||||
// имя окна — ключ задачи; заголовок карточки (h2) подписывает окно через aria-labelledby
|
||||
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
|
||||
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
|
||||
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
|
||||
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
|
||||
// даёт click на самом <dialog>, но нажатие было внутри содержимого
|
||||
const downOnBackdrop = useRef(false);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (d && !d.open) d.showModal();
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden"; // страница под окном не прокручивается
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
return (
|
||||
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title" aria-label={issueKey}
|
||||
<Dialog size="lg" className="issue-modal" label={issueKey} onClose={onClose} aria-labelledby="issue-modal-title"
|
||||
onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
|
||||
onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
|
||||
onNativeClose={(e) => { const d = e.currentTarget; if (d.isConnected) { d.showModal(); onClose(); } }}
|
||||
onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
|
||||
onClick={(e) => {
|
||||
const fromBackdrop = downOnBackdrop.current;
|
||||
@ -29,6 +27,6 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: (
|
||||
if (e.target === e.currentTarget && fromBackdrop) onClose();
|
||||
}}>
|
||||
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useBlocker } from "react-router";
|
||||
import { useCloseAbort } from "./closeAbort.ts";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useSaveNote, useSettings } from "../api/queries.ts";
|
||||
import { Button, Field, Notice, Select, Textarea } from "../ui/index.ts";
|
||||
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
|
||||
|
||||
type Values = Record<NoteField, string>;
|
||||
@ -52,33 +53,37 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
||||
<form className="note" aria-labelledby="notes-title" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<fieldset className="note-fields" disabled={save.isPending}>
|
||||
<label>Вид работы
|
||||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||||
<Field label="Вид работы">
|
||||
<Select value={values.work_kind} onChange={set("work_kind")}>
|
||||
<option value="">—</option>
|
||||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</Field>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
<Field key={f} label={label}><Textarea rows={3} value={values[f]} onChange={set(f)} /></Field>
|
||||
))}
|
||||
</fieldset>
|
||||
{err && <p className="err" role="alert">{err}</p>}
|
||||
{err && <Notice tone="error">{err}</Notice>}
|
||||
<div className="row">
|
||||
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
|
||||
<button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
|
||||
<Button variant="primary" type="submit" disabled={!dirty || save.isPending}>Сохранить</Button>
|
||||
<Button type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</Button>
|
||||
</div>
|
||||
{askCancel && (
|
||||
<div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
|
||||
<div className="note-confirm" role="alertdialog" aria-label="Отменить правки заметки?">
|
||||
<span>Отменить правки заметки?</span>
|
||||
<button className="btn" type="button" onClick={onDone}>Отменить правки</button>
|
||||
<button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
|
||||
<div className="row">
|
||||
<Button onClick={onDone}>Отменить правки</Button>
|
||||
<Button variant="primary" onClick={() => setAskCancel(false)}>Продолжить правку</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blocker.state === "blocked" && (
|
||||
<div className="confirm" role="alertdialog" ref={confirmRef}>
|
||||
<div className="note-confirm" role="alertdialog" ref={confirmRef}>
|
||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
|
||||
<button className="btn" type="button" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</button>
|
||||
<div className="row">
|
||||
<Button onClick={() => blocker.proceed()}>Уйти</Button>
|
||||
<Button variant="primary" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { IssueDetail, NoteField } from "../api/types.ts";
|
||||
import { Button } from "../ui/index.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { NoteForm } from "./NoteForm.tsx";
|
||||
|
||||
@ -15,7 +16,7 @@ export function Notes({ issue }: { issue: IssueDetail }) {
|
||||
<section className="note view" aria-labelledby="notes-title">
|
||||
<div className="sec-head">
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
|
||||
<Button onClick={() => setEditing(true)}>Править</Button>
|
||||
</div>
|
||||
<dl className="fields">
|
||||
{ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
|
||||
|
||||
@ -29,63 +29,92 @@
|
||||
.issue-table td.overdue{color:var(--color-status-error);font-weight:600}
|
||||
.issues__hint{margin:0;color:var(--color-text-secondary)}
|
||||
.issues__more{display:flex;justify-content:center}
|
||||
.issue a{color:var(--accent)}
|
||||
.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
|
||||
.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
|
||||
/* карточка задачи в окне (design 13, 19): всё через токены */
|
||||
.issue{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-primary)}
|
||||
.issue a{color:var(--color-action-primary)}
|
||||
.issue a.ui-btn{color:var(--color-text-primary);text-decoration:none}
|
||||
.issue .hint{margin:0;font-size:var(--fs-body);color:var(--color-text-secondary)}
|
||||
.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 .spacer{flex:1 1 auto}
|
||||
.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
|
||||
.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
|
||||
.issue .overdue{color:var(--p-crit);font-weight:600}
|
||||
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
|
||||
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
|
||||
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.issue .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */
|
||||
.issue .main{min-width:0}
|
||||
.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)}
|
||||
.issue-head .ui-notice{margin-top:var(--space-3)}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:var(--space-6);align-items:start}
|
||||
.issue .main{min-width:0;display:flex;flex-direction:column;gap:var(--space-5)}
|
||||
.issue .side.ui-panel{min-width:0;display:flex;flex-direction:column;gap:var(--space-4);background:var(--color-bg-subtle)}
|
||||
.issue .side>*+*{border-top:1px solid var(--color-border-default);padding-top:var(--space-4)}
|
||||
@media (max-width:899.98px){
|
||||
.issue .cols{display:flex;flex-direction:column;align-items:stretch;gap:16px}
|
||||
.issue .side{display:contents}
|
||||
.issue .cols{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-4)}
|
||||
.issue .main{display:contents}
|
||||
.issue .side.ui-panel{display:contents}
|
||||
/* на узком экране разделы правой колонки — отдельные панели, заметки идут первыми */
|
||||
.issue .side>*{background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-4);min-width:0}
|
||||
.issue .side>*+*{border-top:1px solid var(--color-border-default);padding-top:var(--space-4)}
|
||||
.issue .side .note{order:-1}
|
||||
.issue .side .other{order:1}
|
||||
.issue .side .commands{order:2}
|
||||
}
|
||||
.issue section{margin-bottom:18px}
|
||||
.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
|
||||
.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.issue .group{margin:0 0 10px}
|
||||
.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
|
||||
.issue section,.issue form{margin:0}
|
||||
.issue h3{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:600;color:var(--color-text-primary)}
|
||||
.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h);margin-bottom:var(--space-2)}
|
||||
.issue .sec-head .ui-btn--ghost{padding:0 var(--space-2);margin-right:calc(-1*var(--space-2))}
|
||||
.issue .sec-head .ui-btn--secondary{padding:0 var(--space-3)}
|
||||
.issue .more{padding:0 var(--space-2);margin-left:calc(-1*var(--space-2))}
|
||||
.issue .links-sec>h3{margin-bottom:var(--space-2)}
|
||||
.issue .group{margin:0 0 var(--space-3)}
|
||||
.issue .group:last-child{margin-bottom:0}
|
||||
.issue .group h4{font-size:var(--fs-body);line-height:var(--lh-body);font-weight:600;margin:0 0 var(--space-1);color:var(--color-text-secondary)}
|
||||
.issue .links{list-style:none;margin:0;padding:0}
|
||||
.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
|
||||
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
|
||||
.issue .links .sum{flex:1 1 200px;min-width:0}
|
||||
.issue .links .absent{color:var(--muted);font-style:italic}
|
||||
.issue .description{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
|
||||
.issue pre.description{white-space:pre-wrap;font:inherit}
|
||||
.issue .links li{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);padding:var(--space-1) 0}
|
||||
.issue .links li>a{font-family:var(--font-mono);font-size:13px;text-decoration:none}
|
||||
.issue .links li>a:hover{text-decoration:underline}
|
||||
.issue .links .tag{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary);min-width:76px}
|
||||
.issue .links .sum{flex:1 1 200px;min-width:0;overflow-wrap:anywhere}
|
||||
.issue .links .absent{color:var(--color-text-secondary);font-style:italic}
|
||||
.issue .description{background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-3);margin:0 0 var(--space-1);line-height:1.5;overflow-wrap:anywhere}
|
||||
.issue pre.description{white-space:pre-wrap;font:inherit;line-height:1.5}
|
||||
.issue pre.description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden;
|
||||
/* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */
|
||||
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
|
||||
.issue .comments>ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
padding-bottom:0;border-bottom:var(--space-3) solid var(--color-bg-subtle);box-shadow:0 1px 0 var(--color-border-default)}
|
||||
.issue div.description.clamped{max-height:12em;overflow:hidden;
|
||||
/* рендер: блочное содержимое line-clamp не режет — ограничение высотой ≈ 8 строк (line-height 1.5, design #8, R-5) */
|
||||
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
|
||||
.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px;display:flex;justify-content:space-between;gap:8px}
|
||||
.issue .comments .who .link{font-size:12px}
|
||||
.issue .comments pre.body{white-space:pre-wrap;font:inherit;margin:0}
|
||||
.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
|
||||
.issue .fields dt{font-size:12px;color:var(--muted)}
|
||||
.issue .fields dd{margin:0;white-space:pre-wrap}
|
||||
.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
||||
.issue .commands table{margin-top:8px;font-size:13px}
|
||||
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
|
||||
.note fieldset{display:contents;border:0;margin:0;padding:0;min-width:0}
|
||||
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
|
||||
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
|
||||
.note .err{color:var(--p-crit)}
|
||||
.note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
padding-bottom:0;border-bottom:var(--space-3) solid var(--color-bg-subtle);box-shadow:0 1px 0 var(--color-border-default)}
|
||||
.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 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)}
|
||||
.issue .fields dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.issue .other>h3{margin-bottom:var(--space-2)}
|
||||
.issue .commands>h3{margin-bottom:var(--space-2)}
|
||||
.issue .commands summary{cursor:pointer;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:600;border-radius:var(--radius-xs)}
|
||||
.issue .commands summary:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
.issue .cmd-wrap{margin-top:var(--space-2);overflow-x:auto}
|
||||
.issue .commands table{border-collapse:collapse;font-size:var(--fs-caption);line-height:var(--lh-caption);width:100%}
|
||||
.issue .commands th{text-align:left;font-weight:600;color:var(--color-text-secondary);white-space:nowrap}
|
||||
.issue .commands th,.issue .commands td{padding:var(--space-1) var(--space-2) var(--space-1) 0;border-bottom:1px solid var(--color-border-default);vertical-align:top}
|
||||
/* служебные заметки (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 .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) */
|
||||
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
|
||||
.issue-modal::backdrop{background:var(--color-overlay)}
|
||||
.issue-modal > .issue{padding:0 20px 20px}
|
||||
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}
|
||||
/* окно карточки задачи (design 13): размер и рамка — Dialog size="lg"; прокрутка и шапка — здесь */
|
||||
.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)}
|
||||
/* узкий экран: шапка не липкая, «Закрыть» остаётся справа в первой строке, «Открыть в Jira» уходит во вторую */
|
||||
.issue-head{position:static;padding-top:var(--space-4)}
|
||||
.issue-head .spacer{order:1}
|
||||
.issue-head .ui-icon-btn{order:2}
|
||||
.issue-head a.ui-btn{order:3}
|
||||
}
|
||||
.issue .ui-notice__text{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-primary)}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type DialogHTMLAttributes, type ReactNode, type SyntheticEvent } 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;
|
||||
}) {
|
||||
// окно: родной <dialog> держит фокус и закрывает фон; Esc — onClose, закрытие по фону — на стороне использующего.
|
||||
// Остальные атрибуты <dialog> (onCancel, onClick, aria-labelledby …) проходят насквозь и могут переопределить поведение по
|
||||
// умолчанию; нативное событие close — onNativeClose (имя onClose занято запросом закрытия).
|
||||
type Rest = Omit<DialogHTMLAttributes<HTMLDialogElement>, "className" | "children" | "onClose" | "aria-label">;
|
||||
export function Dialog({ label, onClose, onNativeClose, size = "md", className, children, ...rest }: {
|
||||
label: string; onClose(): void; onNativeClose?(e: SyntheticEvent<HTMLDialogElement>): void;
|
||||
size?: "sm" | "md" | "lg"; className?: string; children: ReactNode;
|
||||
} & Rest) {
|
||||
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(); }}>
|
||||
onCancel={(e) => { e.preventDefault(); onClose(); }} {...rest} onClose={onNativeClose}>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user