diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx b/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx index df7140e48..8c6a70604 100644 --- a/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx @@ -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( + , + ); + + 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; diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.tsx b/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.tsx index 585e336dd..ff4af6791 100644 --- a/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.tsx +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.tsx @@ -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) => ( -
- {presets.map((preset) => ( - - ))} -
- ))} + {Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => { + const isAccessibleQueue = + copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX; + return ( +
+ {presets.map((preset) => { + const className = `image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`; + if (isAccessibleQueue) { + return ( + + ); + } + return ( + { + if (!isLocked) { + onSelectPreset(preset); + } + }} + > + {preset.label} + + ); + })} +
+ ); + })}