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({