音效时长浮层改用参数分组语义并补键盘策略
Project CI / Repository checks (pull_request) Failing after 8s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 2m39s
Project CI / Native shell tests (pull_request) Successful in 12m15s

给浮层原语加 options 变体,含复选框和滑块的时长浮层退回 role=group。

浮层收起 hook 支持 Escape 关闭并把焦点还给触发按钮。

时长浮层打开后主动移焦到自动时长复选框,修正 portal 破坏的 Tab 可达性。

把一直未被 vitest 收录的浮层原语测试文件纳入 include 名单。
This commit is contained in:
2026-08-07 04:43:35 +00:00
parent 7b67d8e9b7
commit 6506e95fa1
6 changed files with 102 additions and 5 deletions
@@ -29,6 +29,28 @@ describe('PlatformFloatingMenu', () => {
expect(onRename).toHaveBeenCalledOnce();
});
it('drops menu semantics for the parameter options variant', () => {
render(
<PlatformFloatingMenu
label="音效时长选项"
variant="options"
placement="top-start"
>
<label>
<input type="checkbox" aria-label="自动时长" readOnly checked />
</label>
<input type="range" aria-label="手动音效时长" readOnly value={5} />
</PlatformFloatingMenu>,
);
// 复选框与滑块都不是 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();
+10 -1
View File
@@ -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<HTMLDivElement> & {
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) => {
@@ -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);
});
});
@@ -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<HTMLButtonElement | null>(null);
const soundAutoDurationRef = useRef<HTMLInputElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(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({
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
variant="options"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
@@ -1316,6 +1328,7 @@ function ImageCanvasAudioGenerationComposerView({
</span>
<label className="image-canvas-editor__sound-effect-auto-duration">
<input
ref={soundAutoDurationRef}
type="checkbox"
aria-label="自动时长"
checked={isAutomaticDuration}
@@ -8,6 +8,8 @@ type UseImageCanvasFloatingOptionDismissOptions = {
isOpen: boolean;
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
onDismiss: () => void;
/** Escape 关闭后把焦点还给的触发按钮;不传则只关闭不移焦。 */
restoreFocusRef?: FloatingOptionBoundaryRef | null;
};
function isEventInsideBoundary(
@@ -35,6 +37,7 @@ export function useImageCanvasFloatingOptionDismiss({
isOpen,
boundaryRefs,
onDismiss,
restoreFocusRef,
}: UseImageCanvasFloatingOptionDismissOptions) {
useEffect(() => {
if (!isOpen || typeof document === 'undefined') {
@@ -52,9 +55,26 @@ export function useImageCanvasFloatingOptionDismiss({
onDismiss();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
// 浮层 portal 到 body,关闭时焦点会掉到 body;只有焦点确实在浮层里才把它还给
// 触发按钮,避免抢走用户正在编辑的输入框。
const shouldRestoreFocus = isEventInsideFloatingMenu(
document.activeElement,
);
onDismiss();
if (shouldRestoreFocus) {
restoreFocusRef?.current?.focus();
}
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [boundaryRefs, isOpen, onDismiss]);
}, [boundaryRefs, isOpen, onDismiss, restoreFocusRef]);
}
+1
View File
@@ -54,6 +54,7 @@ export default defineConfig({
'src/components/common/AutoGrowTextArea.test.tsx',
'src/components/common/CreativeImageInputPanel.test.tsx',
'src/components/common/PlatformDangerConfirmDialog.test.tsx',
'src/components/common/PlatformFloatingMenu.test.tsx',
'src/components/common/PlatformImagePreviewModal.test.tsx',
'src/components/common/PlatformReportDialog.test.tsx',
'src/components/common/PlatformUtilityInfoModal.test.tsx',