Compare commits
4 Commits
a0c15fa127
...
fcce3d859b
| Author | SHA1 | Date | |
|---|---|---|---|
| fcce3d859b | |||
|
|
26491e844c | ||
|
|
bf8d048b67 | ||
|
|
33bb93ac7b |
@ -21,4 +21,11 @@ describe("Teamserver 工作区", () => {
|
|||||||
expect(screen.getByText("NW-042")).toBeInTheDocument();
|
expect(screen.getByText("NW-042")).toBeInTheDocument();
|
||||||
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
|
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("通过拆分后的侧边栏进入主线新增的连接中心", () => {
|
||||||
|
render(<App />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "连接中心" }));
|
||||||
|
expect(screen.getByRole("heading", { name: "连接中心" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Session 表格")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
68
src/App.tsx
68
src/App.tsx
@ -4,6 +4,8 @@ import { SessionQueryIntent, SessionTable } from "./SessionTable";
|
|||||||
import { demoSessions } from "./protocol/fixtures";
|
import { demoSessions } from "./protocol/fixtures";
|
||||||
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
||||||
import { toSessionRow } from "./sessionPresentation";
|
import { toSessionRow } from "./sessionPresentation";
|
||||||
|
import { WorkspaceLayout } from "./shell";
|
||||||
|
import { Button, Icon } from "./ui";
|
||||||
import {
|
import {
|
||||||
createInitialWorkspaceState,
|
createInitialWorkspaceState,
|
||||||
selectActiveTab,
|
selectActiveTab,
|
||||||
@ -25,70 +27,12 @@ export function App() {
|
|||||||
const tableState = { ...tableStates[server.id], filter: server.filter };
|
const tableState = { ...tableStates[server.id], filter: server.filter };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<WorkspaceLayout servers={workspace.serverOrder.map((id) => workspace.servers[id])} server={server} activeTab={activeTab} activePage={view === "connections" ? "连接中心" : activeTab.title} onNavigate={(page) => setView(page === "连接中心" ? "connections" : "sessions")} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}>
|
||||||
<aside className="server-rail" aria-label="Teamserver 列表">
|
|
||||||
<div className="brand">G</div>
|
|
||||||
{workspace.serverOrder.map((serverId) => workspace.servers[serverId]).map((item) => (
|
|
||||||
<button
|
|
||||||
className={`server-button ${item.id === server.id ? "active" : ""}`}
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => dispatch({ type: "switchServer", serverId: item.id })}
|
|
||||||
aria-label={`切换到 ${item.name}`}
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
{item.shortName}
|
|
||||||
<span className={`presence ${item.connection.toLowerCase()}`} />
|
|
||||||
{item.unread > 0 && <span className="badge">{item.unread}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className="server-button add" aria-label="添加 Teamserver">+</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<aside className="project-sidebar">
|
|
||||||
<header>
|
|
||||||
<p className="eyebrow">TEAMSERVER</p>
|
|
||||||
<h1>{server.name}</h1>
|
|
||||||
<span className={`connection ${server.connection.toLowerCase()}`}>{server.connection}</span>
|
|
||||||
</header>
|
|
||||||
<label className="search">
|
|
||||||
<span>⌕</span>
|
|
||||||
<input aria-label="搜索" placeholder="搜索" />
|
|
||||||
<kbd>⌘ K</kbd>
|
|
||||||
</label>
|
|
||||||
<nav aria-label="项目导航">
|
|
||||||
<a>收件箱 <strong>{server.unread}</strong></a>
|
|
||||||
<a className={view === "connections" ? "selected" : ""} onClick={() => setView("connections")}>连接中心</a>
|
|
||||||
<p className="nav-heading">项目 · {server.activeProject}</p>
|
|
||||||
{['Overview', 'Sessions', 'Tasks', 'Events', 'Artifacts', 'Audit'].map((item) => (
|
|
||||||
<a className={view === "sessions" && item === activeTab.title ? "selected" : ""} key={item} onClick={() => setView("sessions")}>{item}</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<p className="memory-note">仅内存状态 · 退出后清除</p>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="workspace">
|
|
||||||
<div className="tabbar" role="tablist" aria-label={`${server.name} 标签`}>
|
|
||||||
{server.tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab.id === activeTab.id}
|
|
||||||
className={tab.id === activeTab.id ? "active" : ""}
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => dispatch({ type: "activateTab", serverId: server.id, tabId: tab.id })}
|
|
||||||
title={`${tab.context.project} / ${tab.context.session}`}
|
|
||||||
>
|
|
||||||
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="contextbar" aria-label="锁定的操作上下文">
|
|
||||||
<span className="context-lock">◆ LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
|
|
||||||
</div>
|
|
||||||
<article className="content">
|
<article className="content">
|
||||||
{view === "connections" ? <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> : <>
|
{view === "connections" ? <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> : <>
|
||||||
<div className="content-heading">
|
<div className="content-heading">
|
||||||
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
||||||
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
<div className="heading-actions"><Button>导出视图</Button><Button intent="primary"><Icon name="add" />新建操作</Button></div>
|
||||||
</div>
|
</div>
|
||||||
<SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => {
|
<SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => {
|
||||||
if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter });
|
if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter });
|
||||||
@ -97,8 +41,6 @@ export function App() {
|
|||||||
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
||||||
</>}
|
</>}
|
||||||
</article>
|
</article>
|
||||||
<footer className="statusbar"><span>● {server.connection}</span><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流 ✓ 已同步</span><span className="status-spacer" />内存工作区</footer>
|
</WorkspaceLayout>
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import "./styles/tokens.css";
|
||||||
|
import "./ui/ui.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
3
src/shell/ContextBar.tsx
Normal file
3
src/shell/ContextBar.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { Icon } from "../ui";
|
||||||
|
import type { OperationTab } from "../workspace";
|
||||||
|
export function ContextBar({ serverName, tab }: { serverName: string; tab: OperationTab }) { return <div className="contextbar" aria-label="锁定的操作上下文"><span className="context-lock"><Icon name="lock" size={12} /> LOCKED</span>{serverName} <i>/</i> {tab.context.project} <i>/</i> <b>{tab.context.session}</b> <i>/</i> {tab.title}</div>; }
|
||||||
7
src/shell/OperationTabs.tsx
Normal file
7
src/shell/OperationTabs.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Icon } from "../ui";
|
||||||
|
import type { OperationTab } from "../workspace";
|
||||||
|
|
||||||
|
export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; }
|
||||||
|
export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose }: OperationTabsProps) {
|
||||||
|
return <div className="tabbar" role="tablist" aria-label={`${serverName} 标签`}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeTabId} className={tab.id === activeTabId ? "active" : ""} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}><span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.title}</span>{onClose && <span className="close" role="button" aria-label={`关闭 ${tab.title}`} tabIndex={0} onClick={(event) => { event.stopPropagation(); onClose(tab.id); }}><Icon name="close" size={12} /></span>}</button>)}</div>;
|
||||||
|
}
|
||||||
8
src/shell/ProjectSidebar.tsx
Normal file
8
src/shell/ProjectSidebar.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Icon, Input, StatusIndicator } from "../ui";
|
||||||
|
import type { TeamserverWorkspace } from "../workspace";
|
||||||
|
|
||||||
|
const navigation = ["Overview", "Sessions", "Tasks", "Events", "Artifacts", "Audit"];
|
||||||
|
export interface ProjectSidebarProps { server: TeamserverWorkspace; activePage: string; onNavigate?: (page: string) => void; }
|
||||||
|
export function ProjectSidebar({ server, activePage, onNavigate }: ProjectSidebarProps) {
|
||||||
|
return <aside className="project-sidebar"><header><p className="eyebrow">TEAMSERVER</p><h1>{server.name}</h1><StatusIndicator status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} /></header><Input className="sidebar-search" aria-label="搜索" placeholder="搜索" leading={<Icon name="search" />} /><nav aria-label="项目导航"><button>收件箱 <strong>{server.unread}</strong></button><button className={activePage === "连接中心" ? "selected" : ""} onClick={() => onNavigate?.("连接中心")}>连接中心</button><p className="nav-heading">项目 · {server.activeProject}</p>{navigation.map((item) => <button className={item === activePage ? "selected" : ""} key={item} onClick={() => onNavigate?.(item)}>{item}</button>)}</nav><p className="memory-note">仅内存状态 · 退出后清除</p></aside>;
|
||||||
|
}
|
||||||
3
src/shell/StatusBar.tsx
Normal file
3
src/shell/StatusBar.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { StatusIndicator } from "../ui";
|
||||||
|
import type { ConnectionState } from "../workspace";
|
||||||
|
export function StatusBar({ connection }: { connection: ConnectionState }) { return <footer className="statusbar"><StatusIndicator status={connection === "CONNECTED" ? "online" : "warning"} label={connection} /><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流:已同步</span><span className="status-spacer" /><span>内存工作区</span></footer>; }
|
||||||
7
src/shell/TeamserverRail.tsx
Normal file
7
src/shell/TeamserverRail.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Badge, Icon, IconButton, StatusIndicator, Tooltip } from "../ui";
|
||||||
|
import type { TeamserverWorkspace } from "../workspace";
|
||||||
|
|
||||||
|
export interface TeamserverRailProps { servers: TeamserverWorkspace[]; activeServerId: string; onSwitch: (serverId: string) => void; onAdd?: () => void; }
|
||||||
|
export function TeamserverRail({ servers, activeServerId, onSwitch, onAdd }: TeamserverRailProps) {
|
||||||
|
return <aside className="server-rail" aria-label="Teamserver 列表"><div className="brand">G</div>{servers.map((server) => <Tooltip content={`${server.name} · ${server.connection}`} key={server.id}><button className={`server-button ${server.id === activeServerId ? "active" : ""}`} onClick={() => onSwitch(server.id)} aria-label={`切换到 ${server.name}`} aria-current={server.id === activeServerId ? "true" : undefined}><span>{server.shortName}</span><StatusIndicator compact status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} />{server.unread > 0 && <Badge tone="danger">{server.unread}</Badge>}</button></Tooltip>)}<IconButton className="server-button add" label="添加 Teamserver" onClick={onAdd}><Icon name="add" /></IconButton></aside>;
|
||||||
|
}
|
||||||
10
src/shell/WorkspaceLayout.tsx
Normal file
10
src/shell/WorkspaceLayout.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import type { OperationTab, TeamserverWorkspace } from "../workspace";
|
||||||
|
import { ContextBar } from "./ContextBar";
|
||||||
|
import { OperationTabs } from "./OperationTabs";
|
||||||
|
import { ProjectSidebar } from "./ProjectSidebar";
|
||||||
|
import { StatusBar } from "./StatusBar";
|
||||||
|
import { TeamserverRail } from "./TeamserverRail";
|
||||||
|
|
||||||
|
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; }
|
||||||
|
export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }
|
||||||
6
src/shell/index.ts
Normal file
6
src/shell/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./ContextBar";
|
||||||
|
export * from "./OperationTabs";
|
||||||
|
export * from "./ProjectSidebar";
|
||||||
|
export * from "./StatusBar";
|
||||||
|
export * from "./TeamserverRail";
|
||||||
|
export * from "./WorkspaceLayout";
|
||||||
@ -6,21 +6,20 @@
|
|||||||
--online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda;
|
--online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); }
|
body { margin: 0; min-width: 0; min-height: 100vh; overflow: auto; background: var(--app); }
|
||||||
button, input, select, textarea { font: inherit; color: inherit; }
|
button, input, select, textarea { font: inherit; color: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; }
|
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: none; box-shadow: var(--focus-ring); position: relative; z-index: 1; }
|
||||||
.shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); }
|
.shell { display: grid; grid-template-columns: 52px 260px minmax(600px, 1fr); min-width: 912px; min-height: 100vh; background: var(--app); }
|
||||||
.server-rail { background: #101318; border-right: 1px solid var(--border); padding: 10px 7px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
.server-rail { background: #101318; border-right: 1px solid var(--border); padding: 10px 7px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
.brand { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3a414c; border-radius: 5px; color: var(--text); background: #1b2027; font: 700 14px ui-monospace, "JetBrains Mono", monospace; margin-bottom: 9px; }
|
.brand { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3a414c; border-radius: 5px; color: var(--text); background: #1b2027; font: 700 14px ui-monospace, "JetBrains Mono", monospace; margin-bottom: 9px; }
|
||||||
.server-button { position: relative; width: 36px; height: 36px; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
|
.server-button { position: relative; width: 36px; height: 36px; display:grid; place-items:center; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
|
||||||
.server-button:hover { background: var(--hover); border-color: #343c47; color: var(--text); }
|
.server-button:hover { background: var(--hover); border-color: #343c47; color: var(--text); }
|
||||||
.server-button.active { background: var(--selected); border-color: #3c4959; color: var(--text); }
|
.server-button.active { background: var(--selected); border-color: #3c4959; color: var(--text); }
|
||||||
.server-button.active::before { content: ""; position: absolute; width: 2px; height: 22px; background: var(--info); left: -8px; top: 6px; border-radius: 1px; }
|
.server-button.active::before { content: ""; position: absolute; width: 2px; height: 22px; background: var(--info); left: -8px; top: 6px; border-radius: 1px; }
|
||||||
.server-button.add { color: var(--muted); font-size: 18px; background: transparent; border: 1px dashed #3a414c; }
|
.server-button.add { color: var(--muted); font-size: 18px; background: transparent; border: 1px dashed #3a414c; }
|
||||||
.presence { position: absolute; width: 8px; height: 8px; border: 2px solid #101318; border-radius: 50%; right: -3px; bottom: -3px; background: var(--online); }
|
.server-button .ui-status { position:absolute;right:-3px;bottom:-3px;background:#101318;border-radius:50%;padding:2px; }
|
||||||
.presence.degraded { background: var(--warning); }
|
.server-button .ui-badge { position:absolute;right:-7px;top:-7px; }
|
||||||
.badge { position: absolute; min-width: 16px; height: 16px; padding: 0 4px; line-height: 16px; background: var(--error); color: white; border-radius: 8px; right: -7px; top: -7px; font: 600 9px ui-monospace, monospace; }
|
|
||||||
.project-sidebar { background: var(--nav); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px 12px 9px; }
|
.project-sidebar { background: var(--nav); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px 12px 9px; }
|
||||||
.project-sidebar header { padding: 0 7px 13px; }
|
.project-sidebar header { padding: 0 7px 13px; }
|
||||||
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .1em; margin: 0 0 6px; }
|
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .1em; margin: 0 0 6px; }
|
||||||
@ -29,15 +28,14 @@ h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; }
|
|||||||
.connection::before { content: "✓"; margin-right: 4px; }
|
.connection::before { content: "✓"; margin-right: 4px; }
|
||||||
.connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; }
|
.connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; }
|
||||||
.connection.degraded::before { content: "!"; }
|
.connection.degraded::before { content: "!"; }
|
||||||
.search, .table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
|
.table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
|
||||||
.search { margin: 8px 1px 17px; padding: 6px 8px; }
|
.sidebar-search { margin: 8px 1px 17px; }
|
||||||
.search input, .table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
|
.table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
|
||||||
kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; }
|
kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; }
|
||||||
nav { display: flex; flex-direction: column; gap: 2px; }
|
nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; }
|
nav button { display: flex; justify-content: space-between; padding: 6px 9px; border:0; border-radius: 4px; background:transparent; color: #aab1bd; font-size: 12px; text-align:left; transition: background 100ms ease; }
|
||||||
nav a { cursor: pointer; }
|
nav button:hover { background: var(--hover); }
|
||||||
nav a:hover { background: var(--hover); }
|
nav button.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
|
||||||
nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
|
|
||||||
nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
||||||
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
|
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
|
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
|
||||||
@ -46,12 +44,12 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
|||||||
.tabbar button { min-width: 132px; max-width: 190px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background 100ms ease; }
|
.tabbar button { min-width: 132px; max-width: 190px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background 100ms ease; }
|
||||||
.tabbar button:hover { background: var(--hover); color: #cbd0d8; }
|
.tabbar button:hover { background: var(--hover); color: #cbd0d8; }
|
||||||
.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
|
.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
|
||||||
.close { color: #69717d; margin-left: 15px; }
|
.tabbar button { display:flex;align-items:center;justify-content:space-between;gap:8px; }.tab-label{overflow:hidden;text-overflow:ellipsis}.close { color: #69717d; display:inline-flex; }
|
||||||
.running { display: inline-block; width: 6px; height: 6px; border-radius: 2px; background: var(--warning); margin-right: 7px; }
|
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
|
||||||
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
|
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
|
||||||
.contextbar i { color: #515967; font-style: normal; }
|
.contextbar i { color: #515967; font-style: normal; }
|
||||||
.contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; }
|
.contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; }
|
||||||
.context-lock { color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
|
.context-lock { display:inline-flex;align-items:center;gap:4px;color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
|
||||||
.content { overflow: auto; padding: 22px 24px 30px; background: var(--content); }
|
.content { overflow: auto; padding: 22px 24px 30px; background: var(--content); }
|
||||||
.content-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; }
|
.content-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; }
|
||||||
.content-heading h2 { font-size: 20px; line-height: 27px; margin: 0; font-weight: 600; letter-spacing: -.01em; }
|
.content-heading h2 { font-size: 20px; line-height: 27px; margin: 0; font-weight: 600; letter-spacing: -.01em; }
|
||||||
@ -95,10 +93,11 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
|||||||
.query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; }
|
.query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
|
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
|
||||||
.statusbar span:first-child { color: #75c99c; }
|
.statusbar { overflow-x:auto;white-space:nowrap; }.statusbar .ui-status { color: #75c99c; }
|
||||||
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
||||||
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
|
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 220px minmax(600px,1fr); min-width:872px; } .content { padding-inline: 16px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important;animation-iteration-count:1!important; } }
|
||||||
|
@media (forced-colors: active) { button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:2px solid Highlight;outline-offset:2px;box-shadow:none; } }
|
||||||
.session-overview > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
.session-overview > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
||||||
.session-overview h2, .session-overview h3 { margin: 0; }
|
.session-overview h2, .session-overview h3 { margin: 0; }
|
||||||
.overview-grid { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 12px; }
|
.overview-grid { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
|||||||
10
src/styles/tokens.css
Normal file
10
src/styles/tokens.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
:root {
|
||||||
|
--color-app: #0f1115; --color-nav: #14171c; --color-content: #181c22; --color-hover: #20252d;
|
||||||
|
--color-selected: #27313d; --color-border: #2a3039; --color-text: #e7eaf0; --color-muted: #929aa8;
|
||||||
|
--color-online: #38b57a; --color-warning: #d9a441; --color-danger: #e05b63; --color-info: #4c8dda;
|
||||||
|
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px;
|
||||||
|
--font-xs: 9px; --font-sm: 11px; --font-md: 13px; --font-lg: 20px;
|
||||||
|
--line-sm: 1.3; --line-md: 1.5; --radius-sm: 4px; --radius-md: 6px;
|
||||||
|
--border-subtle: 1px solid var(--color-border); --z-popover: 30; --z-dialog: 50;
|
||||||
|
--duration-fast: 100ms; --duration-normal: 160ms; --focus-ring: 0 0 0 2px #0f1115, 0 0 0 4px #73b7ff;
|
||||||
|
}
|
||||||
121
src/ui/index.tsx
Normal file
121
src/ui/index.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
|
SelectHTMLAttributes,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type Intent = "neutral" | "primary" | "danger";
|
||||||
|
export type StatusTone = "online" | "warning" | "offline" | "info";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
intent?: Intent;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ intent = "neutral", loading = false, disabled, className = "", children, ...props }: ButtonProps) {
|
||||||
|
return <button className={`ui-button ${intent} ${className}`} disabled={disabled || loading} aria-busy={loading || undefined} {...props}>{loading && <span className="ui-spinner" aria-hidden="true" />}{children}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconButtonProps extends ButtonProps { label: string; }
|
||||||
|
export function IconButton({ label, children, ...props }: IconButtonProps) {
|
||||||
|
return <Button className={`ui-icon-button ${props.className ?? ""}`} aria-label={label} title={props.title ?? label} {...props}>{children}</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({ name, size = 14 }: { name: "add" | "search" | "close" | "lock" | "more" | "columns" | "check" | "warning"; size?: number }) {
|
||||||
|
const paths = {
|
||||||
|
add: <path d="M12 5v14M5 12h14" />,
|
||||||
|
search: <><circle cx="10.5" cy="10.5" r="5.5" /><path d="m15 15 4 4" /></>,
|
||||||
|
close: <path d="m7 7 10 10M17 7 7 17" />,
|
||||||
|
lock: <><rect x="6" y="10" width="12" height="9" rx="2" /><path d="M8.5 10V7.5a3.5 3.5 0 0 1 7 0V10" /></>,
|
||||||
|
more: <><circle cx="6" cy="12" r="1" fill="currentColor" /><circle cx="12" cy="12" r="1" fill="currentColor" /><circle cx="18" cy="12" r="1" fill="currentColor" /></>,
|
||||||
|
columns: <><rect x="4" y="5" width="16" height="14" rx="1" /><path d="M10 5v14m4-14v14" /></>,
|
||||||
|
check: <path d="m5 12 4 4L19 6" />,
|
||||||
|
warning: <><path d="M12 4 21 20H3Z" /><path d="M12 9v5m0 3v.1" /></>,
|
||||||
|
};
|
||||||
|
return <svg className="ui-icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children, tone = "neutral", className = "" }: { children: ReactNode; tone?: StatusTone | "neutral" | "danger"; className?: string }) {
|
||||||
|
return <span className={`ui-badge ${tone} ${className}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusIndicator({ status, label, compact = false }: { status: StatusTone; label: string; compact?: boolean }) {
|
||||||
|
const mark = status === "online" ? <Icon name="check" /> : status === "warning" ? <Icon name="warning" /> : <span aria-hidden="true">—</span>;
|
||||||
|
return <span className={`ui-status ${status} ${compact ? "compact" : ""}`}><span className="ui-status-mark">{mark}</span>{!compact && <span>{label}</span>}<span className="sr-only">{compact ? label : ""}</span></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; leading?: ReactNode; }
|
||||||
|
export function Input({ label, leading, className = "", ...props }: InputProps) {
|
||||||
|
const id = useId();
|
||||||
|
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<span className="ui-input-wrap">{leading}<input id={id} {...props} /></span></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; options: { value: string; label: string }[]; }
|
||||||
|
export function Select({ label, options, className = "", ...props }: SelectProps) {
|
||||||
|
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<select {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({ label, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: ReactNode }) {
|
||||||
|
return <label className="ui-checkbox"><input type="checkbox" {...props} /><span>{label}</span></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsProps { label: string; tabs: { id: string; label: ReactNode; title?: string }[]; activeId: string; onChange: (id: string) => void; }
|
||||||
|
export function Tabs({ label, tabs, activeId, onChange }: TabsProps) {
|
||||||
|
return <div className="ui-tabs" role="tablist" aria-label={label}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeId} className={tab.id === activeId ? "active" : ""} key={tab.id} title={tab.title} onClick={() => onChange(tab.id)}>{tab.label}</button>)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
|
||||||
|
return <span className="ui-tooltip">{children}<span role="tooltip">{content}</span></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlayProps { open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; }
|
||||||
|
function useDismiss(open: boolean, onOpenChange: (open: boolean) => void, container: RefObject<HTMLElement | null>) {
|
||||||
|
const returnFocus = useRef<HTMLElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
returnFocus.current = document.activeElement as HTMLElement;
|
||||||
|
const escape = (event: KeyboardEvent) => event.key === "Escape" && onOpenChange(false);
|
||||||
|
const outside = (event: PointerEvent) => !container.current?.contains(event.target as Node) && onOpenChange(false);
|
||||||
|
document.addEventListener("keydown", escape);
|
||||||
|
document.addEventListener("pointerdown", outside);
|
||||||
|
return () => { document.removeEventListener("keydown", escape); document.removeEventListener("pointerdown", outside); returnFocus.current?.focus(); };
|
||||||
|
}, [open, onOpenChange, container]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Popover({ open, onOpenChange, children }: OverlayProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useDismiss(open, onOpenChange, ref);
|
||||||
|
return open ? <div className="ui-popover" ref={ref} role="dialog">{children}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onOpenChange, title, children }: OverlayProps & { title: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useDismiss(open, onOpenChange, ref);
|
||||||
|
useEffect(() => { if (open) ref.current?.querySelector<HTMLElement>("button, input, select, [tabindex]")?.focus(); }, [open]);
|
||||||
|
if (!open) return null;
|
||||||
|
return <div className="ui-dialog-backdrop" onPointerDown={(event) => event.target === event.currentTarget && onOpenChange(false)}><div className="ui-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId}><header><h2 id={titleId}>{title}</h2><IconButton label="关闭对话框" onClick={() => onOpenChange(false)}><Icon name="close" /></IconButton></header>{children}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
|
||||||
|
return <div className="ui-empty"><strong>{title}</strong>{description && <p>{description}</p>}{action}</div>;
|
||||||
|
}
|
||||||
|
export function Skeleton({ width = "100%", className = "" }: { width?: string | number; className?: string }) { return <span className={`ui-skeleton ${className}`} style={{ width }} aria-hidden="true" />; }
|
||||||
|
|
||||||
|
export function Toast({ tone = "info", children, onDismiss }: { tone?: StatusTone | "danger"; children: ReactNode; onDismiss?: () => void }) {
|
||||||
|
return <div className={`ui-toast ${tone}`} role={tone === "danger" ? "alert" : "status"}>{children}{onDismiss && <IconButton label="关闭通知" onClick={onDismiss}><Icon name="close" /></IconButton>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataToolbar({ children, className = "", ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={`ui-data-toolbar ${className}`} role="toolbar" {...props}>{children}</div>; }
|
||||||
|
|
||||||
|
export function DemoOverlays() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return <><Button onClick={() => setOpen(true)}>打开</Button><Dialog open={open} onOpenChange={setOpen} title="示例">内容</Dialog></>;
|
||||||
|
}
|
||||||
8
src/ui/ui.css
Normal file
8
src/ui/ui.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.ui-button { min-height: 28px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:var(--border-subtle); border-radius:var(--radius-sm); padding:5px 9px; color:var(--color-text); background:#1b2027; font-size:var(--font-sm); }
|
||||||
|
.ui-button:hover:not(:disabled) { background:var(--color-hover); }.ui-button:active:not(:disabled){transform:translateY(1px)}.ui-button:disabled{cursor:not-allowed;opacity:.45}.ui-button.primary{background:#326ba9;border-color:#457dbd}.ui-button.primary:hover:not(:disabled){background:#3d78b8}.ui-button.danger{background:#8e3037;border-color:#b84a53}.ui-icon-button{width:28px;padding:0}.ui-icon{flex:none}.ui-spinner{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-spin .7s linear infinite}
|
||||||
|
.ui-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;padding:0 4px;border-radius:8px;background:#343b45;font:600 var(--font-xs) ui-monospace}.ui-badge.danger{background:var(--color-danger);color:white}
|
||||||
|
.ui-status{display:inline-flex;align-items:center;gap:5px;color:var(--color-muted);font:600 var(--font-xs) ui-monospace}.ui-status-mark{width:15px;height:15px;display:grid;place-items:center;border:1px solid currentColor;border-radius:3px}.ui-status.online{color:#82d5aa}.ui-status.warning{color:#e5bb67}.ui-status.offline{color:#a4abb5}.ui-status.compact .ui-status-mark{width:10px;height:10px;border-radius:50%}.ui-status.compact svg{display:none}
|
||||||
|
.ui-field{display:grid;gap:4px}.ui-field-label{color:var(--color-muted);font-size:var(--font-sm)}.ui-input-wrap,.ui-field select{display:flex;align-items:center;gap:7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#111419;padding:6px 8px}.ui-field input{width:100%;min-width:0;border:0;outline:0;background:transparent}.ui-field select{color:var(--color-text)}.ui-checkbox{display:flex;align-items:center;gap:7px}.ui-tabs{display:flex;overflow:auto}.ui-tabs>button{flex:0 0 auto}.ui-tooltip{position:relative;display:inline-flex}.ui-tooltip [role=tooltip]{position:absolute;left:50%;bottom:calc(100% + 7px);z-index:var(--z-popover);transform:translateX(-50%);visibility:hidden;opacity:0;white-space:nowrap;padding:5px 7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#080a0d;color:var(--color-text);transition:opacity var(--duration-fast)}.ui-tooltip:hover [role=tooltip],.ui-tooltip:focus-within [role=tooltip]{visibility:visible;opacity:1}
|
||||||
|
.ui-popover{position:absolute;z-index:var(--z-popover);padding:10px;border:1px solid #3a424e;border-radius:var(--radius-md);background:#15191f;box-shadow:0 8px 24px #090b0ecc}.ui-dialog-backdrop{position:fixed;inset:0;z-index:var(--z-dialog);display:grid;place-items:center;background:#06080bb8}.ui-dialog{width:min(480px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;border:1px solid #3a424e;border-radius:var(--radius-md);background:var(--color-content);box-shadow:0 20px 60px #000a;padding:16px}.ui-dialog>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.ui-dialog h2{margin:0;font-size:16px}.ui-empty{display:grid;justify-items:center;gap:8px;padding:40px;color:var(--color-muted);text-align:center}.ui-empty p{margin:0}.ui-skeleton{display:inline-block;height:12px;border-radius:3px;background:linear-gradient(90deg,#222831,#343c47,#222831);background-size:200% 100%;animation:ui-shimmer 1.4s ease infinite}.ui-toast{position:fixed;right:16px;bottom:36px;z-index:var(--z-popover);display:flex;align-items:center;gap:12px;max-width:360px;padding:10px 12px;border:var(--border-subtle);border-left:3px solid var(--color-info);border-radius:var(--radius-sm);background:#171b21;box-shadow:0 8px 24px #0008}.ui-toast.danger{border-left-color:var(--color-danger)}.ui-data-toolbar{display:flex;align-items:center;gap:6px}
|
||||||
|
@keyframes ui-spin{to{transform:rotate(360deg)}}@keyframes ui-shimmer{to{background-position:-200% 0}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.ui-spinner,.ui-skeleton{animation:none}.ui-button:active{transform:none}}
|
||||||
13
src/ui/ui.test.tsx
Normal file
13
src/ui/ui.test.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Button, Dialog, Popover, StatusIndicator } from ".";
|
||||||
|
|
||||||
|
function DialogFixture() { const [open, setOpen] = useState(false); return <><button onClick={() => setOpen(true)}>触发器</button><Dialog open={open} onOpenChange={setOpen} title="确认操作"><Button>确认</Button></Dialog></>; }
|
||||||
|
function PopoverFixture() { const [open, setOpen] = useState(true); return <div><Popover open={open} onOpenChange={setOpen}><button>菜单项</button></Popover><button>外部</button></div>; }
|
||||||
|
|
||||||
|
describe("设计系统关键交互", () => {
|
||||||
|
it("Dialog 支持 Escape 关闭并将焦点归还触发器", () => { render(<DialogFixture />); const trigger = screen.getByRole("button", { name: "触发器" }); trigger.focus(); fireEvent.click(trigger); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); });
|
||||||
|
it("Popover 点击外部后关闭", () => { render(<PopoverFixture />); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "外部" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); });
|
||||||
|
it("加载态会禁用按钮,状态同时提供文本", () => { render(<><Button loading>保存</Button><StatusIndicator status="warning" label="DEGRADED" /></>); expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); expect(screen.getByText("DEGRADED")).toBeInTheDocument(); });
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user