Skip to content

Commit 01e114c

Browse files
committed
feat(web): add dialog header alias
1 parent ebab1bb commit 01e114c

9 files changed

Lines changed: 154 additions & 28 deletions

File tree

packages/web/src/components/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export { Input } from "./input";
2323
export type { KbdProps } from "./kbd";
2424
export { Kbd } from "./kbd";
2525
export type { ModalProps, ModalSize } from "./modal";
26-
export { Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from "./modal";
26+
export { DialogHeader, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from "./modal";
2727
export type { NoticeProps, NoticeTone } from "./notice";
2828
export { Notice } from "./notice";
2929
export type { PillProps } from "./pill";

packages/web/src/components/ui/modal/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
```tsx
77
<Modal open onOpenChange={setOpen}>
8-
<ModalHeader>
8+
<DialogHeader>
99
<ModalTitle>Workspace details</ModalTitle>
10-
</ModalHeader>
10+
</DialogHeader>
1111
<ModalBody>Body</ModalBody>
1212
<ModalFooter>Footer</ModalFooter>
1313
</Modal>
@@ -26,4 +26,6 @@
2626
## 注意
2727
- 组件通过 portal 渲染到 `document.body`
2828
- 打开后会把焦点移入弹窗,关闭后恢复到先前焦点。
29+
- `DialogHeader` 是弹层 header 的 canonical 入口;`ModalHeader` 保留为兼容命名。
30+
- 如需标准化 icon / copy / description 结构,使用 `dialog-header__leading``dialog-header__icon``dialog-header__copy``dialog-header__description`
2931
- 迁移期仍保留 `modal-overlay``modal-card``modal-header``modal-title``modal-body``modal-footer` 等兼容类名。

packages/web/src/components/ui/modal/index.module.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,47 @@
5353
border-bottom: 1px solid var(--border);
5454
}
5555

56+
.dialogHeader,
57+
:global(.dialog-header) {
58+
align-items: flex-start;
59+
}
60+
61+
:global(.dialog-header__leading) {
62+
display: flex;
63+
align-items: flex-start;
64+
gap: var(--sp-3);
65+
min-width: 0;
66+
flex: 1;
67+
}
68+
69+
:global(.dialog-header__copy) {
70+
display: flex;
71+
min-width: 0;
72+
flex: 1;
73+
flex-direction: column;
74+
gap: 2px;
75+
}
76+
77+
:global(.dialog-header__icon) {
78+
display: inline-flex;
79+
align-items: center;
80+
justify-content: center;
81+
width: 28px;
82+
height: 28px;
83+
border-radius: var(--radius-md);
84+
flex-shrink: 0;
85+
background: var(--bg-hover);
86+
color: var(--text-secondary);
87+
}
88+
89+
:global(.dialog-header__description) {
90+
margin: 0;
91+
font-size: 13px;
92+
line-height: 20px;
93+
font-weight: 400;
94+
color: var(--text-tertiary);
95+
}
96+
5697
.title,
5798
:global(.modal-title) {
5899
display: flex;
@@ -110,6 +151,8 @@
110151

111152
.header,
112153
:global(.modal-header),
154+
.dialogHeader,
155+
:global(.dialog-header),
113156
.body,
114157
:global(.modal-body),
115158
.footer,

packages/web/src/components/ui/modal/index.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { createRef } from "react";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5-
import { Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from ".";
5+
import { DialogHeader, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from ".";
66

77
const activeElementState = {
88
current: null as HTMLElement | null,
@@ -78,6 +78,20 @@ describe("Modal", () => {
7878
expect(document.body).toContainElement(dialog);
7979
});
8080

81+
it("exports DialogHeader as the canonical modal header alias", () => {
82+
render(
83+
<DialogHeader>
84+
<ModalTitle>Workspace details</ModalTitle>
85+
</DialogHeader>
86+
);
87+
88+
const header = document.querySelector(".dialog-header");
89+
90+
expect(header).not.toBeNull();
91+
expect(header).toHaveClass("modal-header");
92+
expect(screen.getByText("Workspace details")).toBeInTheDocument();
93+
});
94+
8195
it("applies the large card variant for size='lg'", () => {
8296
render(<ModalFixture size="lg" />);
8397

packages/web/src/components/ui/modal/index.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,21 @@ export function ModalHeader({ className, ...props }: HTMLAttributes<HTMLDivEleme
134134
return <div {...props} className={clsx(styles.header, "modal-header", className)} />;
135135
}
136136

137+
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
138+
return (
139+
<div
140+
{...props}
141+
className={clsx(
142+
styles.header,
143+
styles.dialogHeader,
144+
"modal-header",
145+
"dialog-header",
146+
className
147+
)}
148+
/>
149+
);
150+
}
151+
137152
export function ModalTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
138153
const context = useContext(ModalContext);
139154
const fallbackId = useId();

packages/web/src/features/supervisor/components/objective-dialog.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,43 @@ describe("ObjectiveDialog", () => {
203203
expect(screen.getByRole("dialog")).toHaveClass("supervisor-dialog--disable");
204204
});
205205

206+
it("renders the dialog header through the canonical dialog header anatomy", () => {
207+
const store = createStore();
208+
window.localStorage.setItem("ui.locale", JSON.stringify("en"));
209+
store.set(localeAtom, "en");
210+
store.set(wsClientAtom, { sendCommand: vi.fn() } as never);
211+
store.set(
212+
supervisorDialogAtom,
213+
createDialogState({
214+
mode: "disable",
215+
})
216+
);
217+
store.set(supervisorsAtom, new Map([["sess-1", createSupervisor()]]));
218+
219+
render(
220+
<Provider store={store}>
221+
<ObjectiveDialog workspaceId="ws-1" />
222+
</Provider>
223+
);
224+
225+
const dialog = screen.getByRole("dialog");
226+
const header = dialog.querySelector(".dialog-header");
227+
const leading = header?.querySelector(".dialog-header__leading");
228+
const icon = header?.querySelector(".dialog-header__icon");
229+
const copy = header?.querySelector(".dialog-header__copy");
230+
const description = header?.querySelector(".dialog-header__description");
231+
const closeButton = screen.getByRole("button", { name: "Close" });
232+
233+
expect(header).not.toBeNull();
234+
expect(leading).not.toBeNull();
235+
expect(icon).not.toBeNull();
236+
expect(copy).not.toBeNull();
237+
expect(description).toHaveTextContent(
238+
"Stop automatic evaluation. The current session's supervision cycles will be removed."
239+
);
240+
expect(closeButton).toHaveClass("modal-close");
241+
});
242+
206243
it("renders footer actions with shared button compatibility classes", () => {
207244
const store = createStore();
208245
window.localStorage.setItem("ui.locale", JSON.stringify("en"));

packages/web/src/features/supervisor/views/shared/objective-dialog.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { X } from "lucide-react";
22
import {
33
Button,
4+
DialogHeader,
45
IconButton,
56
Modal,
67
ModalBody,
78
ModalFooter,
8-
ModalHeader,
99
ModalTitle,
1010
} from "../../../../components/ui";
1111
import { useViewport } from "../../../../hooks/use-viewport";
@@ -40,23 +40,24 @@ export function ObjectiveDialog({ workspaceId, sessionId }: ObjectiveDialogProps
4040

4141
return (
4242
<Modal className={`supervisor-dialog supervisor-dialog--${mode}`} onOpenChange={close} open>
43-
<ModalHeader>
44-
<div className="supervisor-dialog-header">
45-
<span className="supervisor-dialog-header-icon" aria-hidden="true">
43+
<DialogHeader>
44+
<div className="dialog-header__leading">
45+
<span className="dialog-header__icon supervisor-dialog-header-icon" aria-hidden="true">
4646
<ObjectiveDialogModeIcon mode={mode} />
4747
</span>
48-
<div>
48+
<div className="dialog-header__copy">
4949
<ModalTitle>{copy.title}</ModalTitle>
50-
<span className="supervisor-dialog-subtitle">{copy.subtitle}</span>
50+
<p className="dialog-header__description">{copy.subtitle}</p>
5151
</div>
5252
</div>
5353
<IconButton
5454
aria-label={t("action.close")}
55+
className="modal-close"
5556
icon={<X size={14} />}
5657
onClick={close}
5758
size="sm"
5859
/>
59-
</ModalHeader>
60+
</DialogHeader>
6061

6162
<ModalBody>
6263
<ObjectiveDialogContent

packages/web/src/styles/components.css

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3741,13 +3741,6 @@ body.is-resizing-panels * {
37413741
line-height: 1.25;
37423742
}
37433743

3744-
.supervisor-dialog-header {
3745-
display: flex;
3746-
align-items: center;
3747-
gap: var(--sp-3);
3748-
min-width: 0;
3749-
}
3750-
37513744
.supervisor-dialog-header-icon {
37523745
display: inline-flex;
37533746
align-items: center;
@@ -3759,24 +3752,16 @@ body.is-resizing-panels * {
37593752
color: var(--icon-success);
37603753
}
37613754

3762-
.supervisor-dialog[data-mode="edit"] .supervisor-dialog-header-icon {
3755+
.supervisor-dialog--edit .supervisor-dialog-header-icon {
37633756
background: var(--icon-surface-info);
37643757
color: var(--icon-info);
37653758
}
37663759

3767-
.supervisor-dialog[data-mode="disable"] .supervisor-dialog-header-icon {
3760+
.supervisor-dialog--disable .supervisor-dialog-header-icon {
37683761
background: var(--icon-surface-error);
37693762
color: var(--icon-error);
37703763
}
37713764

3772-
.supervisor-dialog-subtitle {
3773-
display: block;
3774-
margin-top: 2px;
3775-
font-size: var(--text-xs);
3776-
font-weight: var(--font-normal);
3777-
color: var(--text-tertiary);
3778-
}
3779-
37803765
.supervisor-dialog .textarea {
37813766
font-family: var(--font-mono);
37823767
font-size: var(--text-sm);

packages/web/src/styles/components.theme.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const confirmDialogStyles = readFileSync(
2929
`${process.cwd()}/src/components/ui/confirm-dialog/index.module.css`,
3030
"utf8"
3131
);
32+
const modalStylesheet = readFileSync(
33+
`${process.cwd()}/src/components/ui/modal/index.module.css`,
34+
"utf8"
35+
);
3236

3337
function getLastGroupedRuleBlockFrom(source: string, pattern: RegExp) {
3438
const matches = Array.from(source.matchAll(pattern));
@@ -613,6 +617,31 @@ describe("components.css theme-sensitive surfaces", () => {
613617
expect(hasRuleBlock(".workspace-sidebar-panel__header .panel-header__title-row")).toBe(false);
614618
});
615619

620+
it("keeps dialog headers on the approved modal header contract", () => {
621+
const modalTitle = getLastRuleBlockFrom(modalStylesheet, ".title");
622+
const dialogHeader =
623+
getRuleBlocksFrom(modalStylesheet, ".dialogHeader").find((block) =>
624+
block.includes("align-items: flex-start")
625+
) ?? "";
626+
const dialogIcon = getLastRuleBlock(".supervisor-dialog-header-icon");
627+
const editTone = getLastRuleBlock(".supervisor-dialog--edit .supervisor-dialog-header-icon");
628+
const disableTone = getLastRuleBlock(
629+
".supervisor-dialog--disable .supervisor-dialog-header-icon"
630+
);
631+
632+
expect(modalTitle).toContain("font-size: var(--text-lg)");
633+
expect(modalTitle).toContain("font-weight: var(--font-semibold)");
634+
expect(dialogHeader).toContain("align-items: flex-start");
635+
expect(dialogIcon).toContain("width: 28px");
636+
expect(dialogIcon).toContain("height: 28px");
637+
expect(editTone).toContain("var(--icon-surface-info)");
638+
expect(editTone).toContain("var(--icon-info)");
639+
expect(disableTone).toContain("var(--icon-surface-error)");
640+
expect(disableTone).toContain("var(--icon-error)");
641+
expect(hasRuleBlock(".supervisor-dialog-header")).toBe(false);
642+
expect(hasRuleBlock(".supervisor-dialog-subtitle")).toBe(false);
643+
});
644+
616645
it("uses a unified inline sheet treatment for mobile selectors and keeps topbar controls height-aligned", () => {
617646
const inlineSheet = getLastRuleBlock(".mobile-inline-sheet");
618647
const inlineSheetAction = getLastRuleBlock(".mobile-inline-sheet__action");

0 commit comments

Comments
 (0)