























@@ -0,0 +1,175 @@
1+/* @vitest-environment jsdom */
2+3+import { html, nothing, render } from "lit";
4+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+import { type OpenClawModalDialog } from "./modal-dialog.ts";
6+import "./modal-dialog.ts";
7+8+let container: HTMLDivElement;
9+10+const showModalDescriptor = Object.getOwnPropertyDescriptor(
11+HTMLDialogElement.prototype,
12+"showModal",
13+);
14+const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
15+16+function nextFrame() {
17+return new Promise<void>((resolve) => {
18+requestAnimationFrame(() => resolve());
19+});
20+}
21+22+function installDialogPolyfill() {
23+Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
24+configurable: true,
25+value(this: HTMLDialogElement) {
26+this.setAttribute("open", "");
27+},
28+});
29+Object.defineProperty(HTMLDialogElement.prototype, "close", {
30+configurable: true,
31+value(this: HTMLDialogElement) {
32+this.removeAttribute("open");
33+},
34+});
35+}
36+37+function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDescriptor) {
38+if (descriptor) {
39+Object.defineProperty(HTMLDialogElement.prototype, name, descriptor);
40+return;
41+}
42+delete (HTMLDialogElement.prototype as Partial<HTMLDialogElement>)[name];
43+}
44+45+async function renderModal() {
46+render(
47+html`
48+ <openclaw-modal-dialog
49+ label="Confirm action"
50+ description="Review the operation before continuing."
51+ >
52+ <section>
53+ <h2 id="modal-title">Confirm action</h2>
54+ <p id="modal-description">Review the operation before continuing.</p>
55+ <button id="first-action">First</button>
56+ <button id="last-action">Last</button>
57+ </section>
58+ </openclaw-modal-dialog>
59+ `,
60+container,
61+);
62+const modal = container.querySelector<OpenClawModalDialog>("openclaw-modal-dialog");
63+expect(modal).not.toBeNull();
64+await modal!.updateComplete;
65+await nextFrame();
66+const dialog = modal!.shadowRoot?.querySelector("dialog");
67+expect(dialog).not.toBeNull();
68+return { modal: modal!, dialog: dialog! };
69+}
70+71+describe("openclaw-modal-dialog", () => {
72+beforeEach(() => {
73+installDialogPolyfill();
74+container = document.createElement("div");
75+document.body.append(container);
76+});
77+78+afterEach(() => {
79+render(nothing, container);
80+container.remove();
81+restoreDescriptor("showModal", showModalDescriptor);
82+restoreDescriptor("close", closeDescriptor);
83+vi.restoreAllMocks();
84+});
85+86+it("opens a labelled modal dialog with an optional description", async () => {
87+const { modal, dialog } = await renderModal();
88+89+expect(dialog.open).toBe(true);
90+expect(dialog.getAttribute("role")).toBe("dialog");
91+expect(dialog.getAttribute("aria-modal")).toBe("true");
92+const labelId = dialog.getAttribute("aria-labelledby");
93+const descriptionId = dialog.getAttribute("aria-describedby");
94+expect(labelId).toBe("openclaw-modal-dialog-label");
95+expect(descriptionId).toBe("openclaw-modal-dialog-description");
96+expect(dialog.getRootNode()).toBe(modal.shadowRoot);
97+expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull();
98+expect(modal.shadowRoot?.getElementById(labelId!)?.textContent).toBe("Confirm action");
99+expect(modal.shadowRoot?.getElementById(descriptionId!)?.textContent).toBe(
100+"Review the operation before continuing.",
101+);
102+});
103+104+it("focuses the dialog container first", async () => {
105+const { modal, dialog } = await renderModal();
106+107+expect(modal.shadowRoot?.activeElement).toBe(dialog);
108+expect(document.activeElement).not.toBe(container.querySelector("#first-action"));
109+});
110+111+it("cycles Tab and Shift+Tab inside focusable dialog content", async () => {
112+const { dialog } = await renderModal();
113+const first = container.querySelector<HTMLButtonElement>("#first-action");
114+const last = container.querySelector<HTMLButtonElement>("#last-action");
115+expect(first).not.toBeNull();
116+expect(last).not.toBeNull();
117+118+last!.focus();
119+const tab = new KeyboardEvent("keydown", {
120+key: "Tab",
121+bubbles: true,
122+cancelable: true,
123+composed: true,
124+});
125+last!.dispatchEvent(tab);
126+expect(tab.defaultPrevented).toBe(true);
127+expect(document.activeElement).toBe(first);
128+129+first!.focus();
130+const shiftTab = new KeyboardEvent("keydown", {
131+key: "Tab",
132+shiftKey: true,
133+bubbles: true,
134+cancelable: true,
135+composed: true,
136+});
137+first!.dispatchEvent(shiftTab);
138+expect(shiftTab.defaultPrevented).toBe(true);
139+expect(document.activeElement).toBe(last);
140+expect(dialog.open).toBe(true);
141+});
142+143+it("emits modal-cancel on Escape", async () => {
144+const { modal, dialog } = await renderModal();
145+const onCancel = vi.fn();
146+modal.addEventListener("modal-cancel", onCancel);
147+148+dialog.dispatchEvent(
149+new KeyboardEvent("keydown", {
150+key: "Escape",
151+bubbles: true,
152+cancelable: true,
153+composed: true,
154+}),
155+);
156+157+expect(onCancel).toHaveBeenCalledTimes(1);
158+});
159+160+it("restores focus when closed and removed", async () => {
161+const returnTarget = document.createElement("button");
162+returnTarget.textContent = "Return";
163+document.body.append(returnTarget);
164+returnTarget.focus();
165+166+await renderModal();
167+expect(document.activeElement).not.toBe(returnTarget);
168+169+render(nothing, container);
170+await nextFrame();
171+172+expect(document.activeElement).toBe(returnTarget);
173+returnTarget.remove();
174+});
175+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。