diff --git a/src/components/image-editor/ImageCanvasSceneStyleControl.test.tsx b/src/components/image-editor/ImageCanvasSceneStyleControl.test.tsx new file mode 100644 index 000000000..e657076fe --- /dev/null +++ b/src/components/image-editor/ImageCanvasSceneStyleControl.test.tsx @@ -0,0 +1,76 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { ImageCanvasSceneStyleControl } from './ImageCanvasSceneStyleControl'; + +function SceneStyleControlHarness() { + const [dialog, setDialog] = useState({ + mode: 'scene', + prompt: '', + status: 'idle', + sceneStylePreset: 'anime', + }); + + return dialog ? ( + node} + buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} + /> + ) : null; +} + +describe('ImageCanvasSceneStyleControl', () => { + it('仅在指针停留于对应预览按钮时挂载一张预览图', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '画风预设 日系动画' })); + + expect(screen.queryByRole('img')).toBeNull(); + + const animePreviewButton = screen.getByRole('button', { + name: '预览日系动画画风', + }); + await user.hover(animePreviewButton); + + expect( + screen.getByRole('img', { name: '日系动画固定画风预览' }), + ).toBeTruthy(); + expect(screen.getAllByRole('img')).toHaveLength(1); + + await user.unhover(animePreviewButton); + + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('键盘聚焦预览按钮时挂载图片,焦点离开后卸载', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '画风预设 日系动画' })); + await user.tab(); + await user.tab(); + + const animePreviewButton = screen.getByRole('button', { + name: '预览日系动画画风', + }); + expect(document.activeElement).toBe(animePreviewButton); + expect( + screen.getByRole('img', { name: '日系动画固定画风预览' }), + ).toBeTruthy(); + + await user.tab(); + + expect(document.activeElement).toBe( + screen.getByRole('menuitemradio', { name: /清透水彩/u }), + ); + expect(screen.queryByRole('img')).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasSceneStyleControl.tsx b/src/components/image-editor/ImageCanvasSceneStyleControl.tsx index f43e65dbf..86d5875ba 100644 --- a/src/components/image-editor/ImageCanvasSceneStyleControl.tsx +++ b/src/components/image-editor/ImageCanvasSceneStyleControl.tsx @@ -49,11 +49,20 @@ export function ImageCanvasSceneStyleControl({ buildPortalMenuStyle, }: ImageCanvasSceneStyleControlProps) { const [isOpen, setIsOpen] = useState(false); + const [hoveredPreview, setHoveredPreview] = + useState(null); + const [focusedPreview, setFocusedPreview] = + useState(null); const triggerRef = useRef(null); - const dismiss = useCallback(() => setIsOpen(false), []); + const dismiss = useCallback(() => { + setIsOpen(false); + setHoveredPreview(null); + setFocusedPreview(null); + }, []); const selectedValue = dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET; const selectedLabel = getEditorSceneStylePresetLabel(selectedValue); + const activePreview = hoveredPreview ?? focusedPreview; useImageCanvasFloatingOptionDismiss({ isOpen, @@ -76,7 +85,7 @@ export function ImageCanvasSceneStyleControl({ } : currentDialog, ); - setIsOpen(false); + dismiss(); }; return ( @@ -88,7 +97,13 @@ export function ImageCanvasSceneStyleControl({ aria-expanded={isOpen} disabled={dialog.status === 'generating'} trailingIcon={} - onClick={() => setIsOpen((current) => !current)} + onClick={() => { + if (isOpen) { + dismiss(); + return; + } + setIsOpen(true); + }} >