音效时长浮层改用参数分组语义并补键盘策略
给浮层原语加 options 变体,含复选框和滑块的时长浮层退回 role=group。 浮层收起 hook 支持 Escape 关闭并把焦点还给触发按钮。 时长浮层打开后主动移焦到自动时长复选框,修正 portal 破坏的 Tab 可达性。 把一直未被 vitest 收录的浮层原语测试文件纳入 include 名单。
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user