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