fix: keep full-window dialogs responsive to mouse input
Render feedback, parsing diagnostics, knowledge confirmations, WeChat binding and image preview overlays through body portals so native titlebar drag regions cannot intercept their controls. Preserve themed form styles and modal focus isolation. Validation: 320 targeted tests, typecheck and lint passed. Native Windows hover and close checks passed for feedback and parsing diagnostics in light and dark themes at two sizes. Full suite timed out without a summary. Release note: 修复反馈等弹窗与窗口标题栏重叠时,关闭按钮无法正常悬停和点击的问题。
This commit is contained in:
@@ -5693,6 +5693,7 @@ describe("App", () => {
|
||||
const imageDialog = await screen.findByRole("dialog", {
|
||||
name: "页面截图.png",
|
||||
});
|
||||
expect(imageDialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(
|
||||
within(imageDialog).getByRole("img", { name: "页面截图.png" }),
|
||||
).toHaveAttribute("src", imageAttachment.contentUrl);
|
||||
@@ -5700,7 +5701,7 @@ describe("App", () => {
|
||||
name: "关闭图片查看器",
|
||||
});
|
||||
expect(closeViewer).toHaveFocus();
|
||||
expect(document.querySelector("main")?.inert).toBe(true);
|
||||
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(true);
|
||||
fireEvent.keyDown(closeViewer, { key: "Tab" });
|
||||
expect(
|
||||
within(imageDialog).getByRole("button", {
|
||||
@@ -5712,7 +5713,7 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "页面截图.png" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector("main")?.inert).toBe(false);
|
||||
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(false);
|
||||
fireEvent.click(
|
||||
within(userArticle).getByRole("button", {
|
||||
name: "下载图片 页面截图.png",
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
@@ -10928,7 +10929,7 @@ function App(): React.JSX.Element {
|
||||
reference={citationDialog.reference}
|
||||
/>
|
||||
)}
|
||||
{imageViewerItem && (
|
||||
{imageViewerItem && createPortal(
|
||||
<div
|
||||
className="image-viewer-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
@@ -10980,7 +10981,8 @@ function App(): React.JSX.Element {
|
||||
<img alt={imageViewerItem.title} src={imageViewerItem.src} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{customTaskDialog && activeProject?.kind === "user" && (
|
||||
<CustomTaskDialog
|
||||
|
||||
@@ -363,6 +363,9 @@ describe('ChannelSettingsSection', () => {
|
||||
const close = await screen.findByRole('button', {
|
||||
name: '关闭微信绑定'
|
||||
})
|
||||
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(
|
||||
document.body
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'请在微信中依次打开“设置 → 插件 → ClawBot → 开始扫一扫”,扫描下方二维码。二维码不会发送到第三方页面。'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
ChannelConnectionTestResult,
|
||||
@@ -512,7 +513,7 @@ function WeixinQrDialog({
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="channel-qr-backdrop">
|
||||
<section
|
||||
aria-labelledby="channel-qr-title"
|
||||
@@ -639,7 +640,8 @@ function WeixinQrDialog({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
})
|
||||
|
||||
it('runs a real-file diagnostic flow and displays its result', async () => {
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
const { container } = render(<DocumentParsingSettingsSection />)
|
||||
await screen.findByText('PP-OCRv6 Tiny')
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
@@ -525,15 +525,13 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '解析测试结果'
|
||||
})
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
expect(dialog).toHaveTextContent('扫描件识别正文')
|
||||
const close = screen.getByRole('button', {
|
||||
name: '关闭结果'
|
||||
})
|
||||
await waitFor(() => expect(close).toHaveFocus())
|
||||
const backgroundSection = trigger.closest<HTMLElement>(
|
||||
'.settings-section'
|
||||
)
|
||||
expect(backgroundSection?.inert).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
expect(
|
||||
fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||
).toBe(false)
|
||||
@@ -544,7 +542,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(backgroundSection?.inert).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
@@ -104,7 +105,7 @@ function DiagnosticDialog({
|
||||
useEffect(() => {
|
||||
return activateModalFocus(() => closeRef.current)
|
||||
}, [])
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className="document-parsing-diagnostic-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
@@ -190,7 +191,8 @@ function DiagnosticDialog({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ describe('FeedbackDialog', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '提交反馈'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
const category = screen.getByLabelText('反馈类型')
|
||||
expect(category).toHaveFocus()
|
||||
fireEvent.change(screen.getByLabelText('标题'), {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { AppInfo } from '../../shared/contracts'
|
||||
import {
|
||||
feedbackCategories,
|
||||
@@ -326,7 +327,7 @@ export function FeedbackDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -760,6 +761,7 @@ export function FeedbackDialog({
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,6 +385,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
'将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。'
|
||||
)
|
||||
expect(onRemoveSource).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '移除来源' })
|
||||
)
|
||||
@@ -1920,7 +1921,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
'将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。'
|
||||
)
|
||||
expect(onDeleteEntity).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '删除关系 使用' })
|
||||
@@ -2086,7 +2089,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
})
|
||||
|
||||
it('isolates and traps focus in the library edit dialog', async () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
const { container } = render(<KnowledgeWorkspace {...createProps()} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '编辑' })
|
||||
trigger.focus()
|
||||
@@ -2095,13 +2098,10 @@ describe('KnowledgeWorkspace', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '编辑知识库'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
const nameInput = screen.getByLabelText('名称')
|
||||
expect(nameInput).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true })
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存修改' })
|
||||
@@ -2109,16 +2109,12 @@ describe('KnowledgeWorkspace', () => {
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms that deleting a managed library removes managed copies', async () => {
|
||||
const onDeleteLibrary = vi.fn()
|
||||
render(
|
||||
const { container } = render(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ onDeleteLibrary })}
|
||||
/>
|
||||
@@ -2131,19 +2127,12 @@ describe('KnowledgeWorkspace', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '删除知识库确认'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
fireEvent.click(trigger)
|
||||
expect(
|
||||
screen.getByText(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type {
|
||||
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
|
||||
@@ -890,7 +891,7 @@ function EditLibraryDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('edit.ariaLabel')}
|
||||
aria-modal="true"
|
||||
@@ -961,7 +962,8 @@ function EditLibraryDialog({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -998,7 +1000,7 @@ function DeleteLibraryDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('delete.ariaLabel')}
|
||||
aria-modal="true"
|
||||
@@ -1056,7 +1058,8 @@ function DeleteLibraryDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1097,7 +1100,7 @@ function RemoveSourceDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -1155,7 +1158,8 @@ function RemoveSourceDialog({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1248,7 +1252,7 @@ function GraphDestructiveDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -1296,7 +1300,8 @@ function GraphDestructiveDialog({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6955,12 +6955,13 @@ button > svg {
|
||||
}
|
||||
|
||||
.image-viewer-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
inset: 38px 0 0;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -9935,12 +9936,13 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.channel-qr-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
inset: 38px 0 0;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -10060,6 +10062,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.feedback-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -10749,6 +10752,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12794,6 +12798,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-confirm-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12836,6 +12841,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-library-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12890,7 +12896,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.knowledge-page
|
||||
:is(.knowledge-page, .knowledge-library-dialog)
|
||||
:is(
|
||||
input:not([type]),
|
||||
input[type='text'],
|
||||
@@ -15954,7 +15960,15 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell strong {
|
||||
:root[data-theme='dark'] :is(
|
||||
.app-shell,
|
||||
.feedback-dialog,
|
||||
.document-parsing-diagnostic,
|
||||
.knowledge-library-dialog,
|
||||
.knowledge-confirm-dialog,
|
||||
.channel-qr-dialog,
|
||||
.image-viewer-dialog
|
||||
) strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user