From 6506e95fa1efd8a398ab3f50be0658780543b108 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 7 Aug 2026 04:43:35 +0000 Subject: [PATCH] =?UTF-8?q?=E9=9F=B3=E6=95=88=E6=97=B6=E9=95=BF=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E6=94=B9=E7=94=A8=E5=8F=82=E6=95=B0=E5=88=86=E7=BB=84?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=B9=B6=E8=A1=A5=E9=94=AE=E7=9B=98=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给浮层原语加 options 变体,含复选框和滑块的时长浮层退回 role=group。 浮层收起 hook 支持 Escape 关闭并把焦点还给触发按钮。 时长浮层打开后主动移焦到自动时长复选框,修正 portal 破坏的 Tab 可达性。 把一直未被 vitest 收录的浮层原语测试文件纳入 include 名单。 --- .../common/PlatformFloatingMenu.test.tsx | 22 +++++++++++ .../common/PlatformFloatingMenu.tsx | 11 +++++- ...ImageCanvasGenerationComposerView.test.tsx | 38 +++++++++++++++++-- .../ImageCanvasGenerationComposerView.tsx | 13 +++++++ .../useImageCanvasFloatingOptionDismiss.ts | 22 ++++++++++- vitest.config.ts | 1 + 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/components/common/PlatformFloatingMenu.test.tsx b/src/components/common/PlatformFloatingMenu.test.tsx index e4dad62c3..76bc8d47c 100644 --- a/src/components/common/PlatformFloatingMenu.test.tsx +++ b/src/components/common/PlatformFloatingMenu.test.tsx @@ -29,6 +29,28 @@ describe('PlatformFloatingMenu', () => { expect(onRename).toHaveBeenCalledOnce(); }); + it('drops menu semantics for the parameter options variant', () => { + render( + + + + , + ); + + // 复选框与滑块都不是 menu 的合法子角色,浮层必须退回普通分组语义。 + expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy(); + expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); + expect(screen.getByRole('checkbox', { name: '自动时长' })).toBeTruthy(); + expect(screen.getByRole('slider', { name: '手动音效时长' })).toBeTruthy(); + }); + it('keeps pointer events from leaking to canvas-style parents', () => { const onParentPointerDown = vi.fn(); const onMenuPointerDown = vi.fn(); diff --git a/src/components/common/PlatformFloatingMenu.tsx b/src/components/common/PlatformFloatingMenu.tsx index 1fcf7ab1d..fd4c25fd6 100644 --- a/src/components/common/PlatformFloatingMenu.tsx +++ b/src/components/common/PlatformFloatingMenu.tsx @@ -1,11 +1,19 @@ import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react'; +/** + * `menu` 承载纯动作集合,子节点必须全是 `PlatformFloatingMenuItem`; + * `options` 承载参数控件(复选框、滑块、选项按钮),它们不是 menuitem, + * 放进 `role="menu"` 既违反 ARIA 子角色约束,也会让读屏进入菜单模式后读不到滑块。 + */ +type PlatformFloatingMenuVariant = 'menu' | 'options'; + type PlatformFloatingMenuProps = HTMLAttributes & { children: ReactNode; className?: string; label?: string; placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; style?: CSSProperties; + variant?: PlatformFloatingMenuVariant; }; type PlatformFloatingMenuItemProps = Omit< @@ -26,6 +34,7 @@ export function PlatformFloatingMenu({ label, placement = 'top-end', style, + variant = 'menu', onPointerDown, ...divProps }: PlatformFloatingMenuProps) { @@ -39,7 +48,7 @@ export function PlatformFloatingMenu({ ] .filter(Boolean) .join(' ')} - role="menu" + role={variant === 'options' ? 'group' : 'menu'} aria-label={label} style={style} onPointerDown={(event) => { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 8ef17d601..71658f146 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -1007,7 +1007,7 @@ describe('ImageCanvasGenerationComposerView', () => { fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), ); - const optionPanel = screen.getByRole('menu', { name: '音效时长选项' }); + const optionPanel = screen.getByRole('group', { name: '音效时长选项' }); const durationSlider = within(optionPanel).getByRole('slider', { name: '手动音效时长', }) as HTMLInputElement; @@ -1592,7 +1592,7 @@ describe('ImageCanvasGenerationComposerView', () => { }); fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' })); - expect(screen.getByRole('menu', { name: '音效时长选项' })).toBeTruthy(); + expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy(); const backgroundMusicDialog = createBackgroundMusicDialog({ id: 'dialog-bgm-locked', @@ -1606,7 +1606,7 @@ describe('ImageCanvasGenerationComposerView', () => { />, ); - expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); + expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull(); expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true'); view.rerender( @@ -1634,7 +1634,39 @@ describe('ImageCanvasGenerationComposerView', () => { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull(); + }); + + it('音效时长浮层是参数分组,打开后移焦并支持 Escape 关闭回焦', () => { + const soundEffectDialog: GenerateDialogState = { + id: 'dialog-sfx-options-a11y', + mode: 'audio-sound-effect', + prompt: '开门声', + status: 'idle', + composerOpen: true, + soundModel: 'eleven_text_to_sound_v2', + soundDurationSeconds: 5, + }; + renderComposer(soundEffectDialog); + + const trigger = screen.getByRole('button', { name: '音效时长 5秒' }); + fireEvent.click(trigger); + + // 复选框和滑块不是 menuitem,浮层不能再声明成菜单。 + const optionPanel = screen.getByRole('group', { name: '音效时长选项' }); expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); + expect(within(optionPanel).queryAllByRole('menuitem')).toHaveLength(0); + + // 浮层 portal 到 body,必须主动移焦,否则参数控件键盘不可达。 + const automaticDuration = within(optionPanel).getByRole('checkbox', { + name: '自动时长', + }); + expect(document.activeElement).toBe(automaticDuration); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull(); + expect(document.activeElement).toBe(trigger); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 9c085bb8b..0086e4b35 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -16,6 +16,7 @@ import { type RefObject, type SetStateAction, useCallback, + useEffect, useId, useRef, useState, @@ -836,6 +837,7 @@ function ImageCanvasAudioGenerationComposerView({ }) { const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false); const soundOptionsButtonRef = useRef(null); + const soundAutoDurationRef = useRef(null); const soundModelButtonRef = useRef(null); const promptCounterId = useId(); const isSoundEffect = dialog.mode === 'audio-sound-effect'; @@ -845,8 +847,17 @@ function ImageCanvasAudioGenerationComposerView({ isOpen: isSoundEffect && isSoundOptionsOpen, boundaryRefs: [soundOptionsButtonRef], onDismiss: () => setIsSoundOptionsOpen(false), + restoreFocusRef: soundOptionsButtonRef, }); + useEffect(() => { + if (!isSoundEffect || !isSoundOptionsOpen) { + return; + } + // 浮层 portal 到 body,Tab 顺序离触发按钮很远;不主动移焦,复选框和滑块键盘不可达。 + soundAutoDurationRef.current?.focus(); + }, [isSoundEffect, isSoundOptionsOpen]); + if (!isSoundEffect) { const backgroundMusicDialog = toBackgroundMusicGenerationDialog(dialog); const backgroundMusicPromptAssist = promptAssist; @@ -1303,6 +1314,7 @@ function ImageCanvasAudioGenerationComposerView({