修复背景音乐预设克隆焦点
Project CI / Repository checks (pull_request) Successful in 9m55s
Project CI / Backend tests (pull_request) Successful in 12m19s
Project CI / Frontend tests (pull_request) Successful in 15m31s
Project CI / Native shell tests (pull_request) Successful in 27m2s

将中间队列设为唯一可访问按钮并移除视觉克隆的焦点能力
为键盘预设补充可见焦点样式
补充克隆点击、锁定及键盘导航回归测试
This commit is contained in:
2026-08-06 06:33:17 +00:00
parent 9671944e0f
commit a786fa7562
3 changed files with 127 additions and 40 deletions
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasBackgroundMusicPresetMarquee } from './ImageCanvasBackgroundMusicPresetMarquee';
@@ -458,7 +459,8 @@ describe('ImageCanvasBackgroundMusicPresetMarquee', () => {
expect(frameCallbacks.size).toBe(0);
});
it('keeps exactly one keyboard entry per preset across the visual clones', () => {
it('keeps the middle queue as the only keyboard entry for every preset', async () => {
const user = userEvent.setup();
const { container } = renderMarquee();
expand();
@@ -468,28 +470,39 @@ describe('ImageCanvasBackgroundMusicPresetMarquee', () => {
),
);
expect(queues).toHaveLength(3);
const [accessibleQueue, firstClone, secondClone] = queues as [
const [firstClone, accessibleQueue, secondClone] = queues as [
HTMLElement,
HTMLElement,
HTMLElement,
];
expect(accessibleQueue.getAttribute('aria-hidden')).toBeNull();
expect(firstClone.getAttribute('aria-hidden')).toBe('true');
expect(accessibleQueue.getAttribute('aria-hidden')).toBeNull();
expect(secondClone.getAttribute('aria-hidden')).toBe('true');
for (const preset of BACKGROUND_MUSIC_PROMPT_PRESETS) {
expect(screen.getAllByRole('button', { name: preset.label })).toHaveLength(
1,
);
expect(
screen.getAllByRole('button', { name: preset.label }),
).toHaveLength(1);
}
const clonedButtons = firstClone.querySelectorAll('button');
expect(clonedButtons).toHaveLength(BACKGROUND_MUSIC_PROMPT_PRESETS.length);
for (const button of clonedButtons) {
expect(button.getAttribute('tabindex')).toBe('-1');
for (const clone of [firstClone, secondClone]) {
expect(clone.querySelectorAll('button, [tabindex]')).toHaveLength(0);
}
const leftArrow = screen.getByRole('button', {
name: '预设向左滚动',
});
leftArrow.focus();
await user.tab();
expect(document.activeElement).toBe(
within(accessibleQueue).getByRole('button', {
name: BACKGROUND_MUSIC_PROMPT_PRESETS[0].label,
}),
);
expect(frameCallbacks.size).toBe(0);
});
it('maps clicks on a visual clone to the same preset action', () => {
it('maps clicks on both visual clones without moving focus into aria-hidden', async () => {
const user = userEvent.setup();
const onSelectPreset = vi.fn();
const { container } = renderMarquee({ onSelectPreset });
expand();
@@ -497,22 +510,62 @@ describe('ImageCanvasBackgroundMusicPresetMarquee', () => {
const queues = container.querySelectorAll(
'.image-canvas-editor__background-music-presets-queue',
);
const clonedButton = within(queues[2] as HTMLElement).getByText(
BACKGROUND_MUSIC_PROMPT_PRESETS[3].label,
const firstClone = queues[0] as HTMLElement;
const secondClone = queues[2] as HTMLElement;
await user.click(
within(firstClone).getByText(BACKGROUND_MUSIC_PROMPT_PRESETS[2].label),
);
fireEvent.click(clonedButton);
expect(firstClone.contains(document.activeElement)).toBe(false);
await user.click(
within(secondClone).getByText(BACKGROUND_MUSIC_PROMPT_PRESETS[3].label),
);
expect(secondClone.contains(document.activeElement)).toBe(false);
expect(onSelectPreset).toHaveBeenCalledWith(
expect(onSelectPreset).toHaveBeenNthCalledWith(
1,
BACKGROUND_MUSIC_PROMPT_PRESETS[2],
);
expect(onSelectPreset).toHaveBeenNthCalledWith(
2,
BACKGROUND_MUSIC_PROMPT_PRESETS[3],
);
});
it('keeps visual clones inactive while the expanded panel is locked', async () => {
const user = userEvent.setup();
const onSelectPreset = vi.fn();
const { container, rerender } = renderMarquee({ onSelectPreset });
expand();
rerender(
<ImageCanvasBackgroundMusicPresetMarquee
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
isLocked
onSelectPreset={onSelectPreset}
/>,
);
const queues = container.querySelectorAll(
'.image-canvas-editor__background-music-presets-queue',
);
const clonedPreset = within(queues[2] as HTMLElement).getByText(
BACKGROUND_MUSIC_PROMPT_PRESETS[3].label,
);
expect(
clonedPreset.classList.contains(
'image-canvas-editor__background-music-preset--disabled',
),
).toBe(true);
await user.click(clonedPreset);
expect(onSelectPreset).not.toHaveBeenCalled();
});
it('re-anchors to the measured queue width after a resize', () => {
const { container } = renderMarquee();
expand();
const viewport = getViewport(container);
const queue = container.querySelector(
'.image-canvas-editor__background-music-presets-queue',
'.image-canvas-editor__background-music-presets-queue:not([aria-hidden])',
) as HTMLDivElement;
queue.getBoundingClientRect = () => ({ width: QUEUE_WIDTH }) as DOMRect;
@@ -13,6 +13,8 @@ import type { BackgroundMusicPromptPreset } from './ImageCanvasBackgroundMusicPr
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
const PRESET_QUEUE_COPIES = 3;
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
const PRESET_BASE_VELOCITY = 26;
/**
@@ -369,29 +371,53 @@ export function ImageCanvasBackgroundMusicPresetMarquee({
onFocus={() => setIsKeyboardFocused(true)}
onBlur={() => setIsKeyboardFocused(false)}
>
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => (
<div
key={copyIndex}
ref={copyIndex === 0 ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 只有第一份队列进入可访问树,其余视觉克隆不可聚焦,
// 保证每个预设只有一个键盘入口。
aria-hidden={copyIndex === 0 ? undefined : true}
>
{presets.map((preset) => (
<button
key={preset.id}
type="button"
className={`image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`}
disabled={isLocked}
tabIndex={copyIndex === 0 ? undefined : -1}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
))}
</div>
))}
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
const isAccessibleQueue =
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
return (
<div
key={copyIndex}
ref={isAccessibleQueue ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
aria-hidden={isAccessibleQueue ? undefined : true}
>
{presets.map((preset) => {
const className = `image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`;
if (isAccessibleQueue) {
return (
<button
key={preset.id}
type="button"
className={className}
disabled={isLocked}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
);
}
return (
<span
key={preset.id}
className={`${className}${
isLocked
? ' image-canvas-editor__background-music-preset--disabled'
: ''
}`}
onClick={() => {
if (!isLocked) {
onSelectPreset(preset);
}
}}
>
{preset.label}
</span>
);
})}
</div>
);
})}
</div>
<button
type="button"
+9 -1
View File
@@ -16908,9 +16908,17 @@ button {
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.image-canvas-editor__background-music-preset:disabled {
.image-canvas-editor__background-music-preset:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: -2px;
}
.image-canvas-editor__background-music-preset:disabled,
.image-canvas-editor__background-music-preset--disabled {
opacity: 0.6;
cursor: not-allowed;
}