修复背景音乐预设克隆焦点
将中间队列设为唯一可访问按钮并移除视觉克隆的焦点能力 为键盘预设补充可见焦点样式 补充克隆点击、锁定及键盘导航回归测试
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user