import { expect, test } from './fixtures.js' async function setup(page, mode = 'normal') { await page.goto('/test/player.html?core=candidate&chapter=published') await page.evaluate((mode) => { window.selectorCalls = [] window.selectorClicks = [] window.selectorHotkeys = [] window.pendingSelections = {} window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, hotkey: true, fullscreenWeb: true }) window.art.on('hotkey', event => window.selectorHotkeys.push(event.code)) const selector = mode === 'empty' ? [] : mode === 'complex' ? [{ html: '', value: 'native' }, { html: '', value: 'input' }] : ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'].map((html, index) => ({ html, value: html, default: index === 1 })) window.art.controls.add({ name: 'keyboard-selector', position: 'right', html: 'Bravo', tooltip: 'Quality', selector, click(component, event) { window.selectorClicks.push([this === window.art, component === this.controls, event.type]) }, onSelect(item, element, event) { window.selectorCalls.push([this === window.art, item.value, element === item.$control_item, event.type]) if (mode === 'async') return new Promise(resolve => window.pendingSelections[item.value] = resolve) if (mode === 'external-focus') document.querySelector('#play').focus() return item.html } }) }, mode) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) } const trigger = page => page.locator('.art-control-keyboard-selector .art-selector-value') const list = page => page.locator('.art-control-keyboard-selector .art-selector-list') const item = (page, index) => list(page).locator('.art-selector-item').nth(index) test('candidate: selector Tab entry, focus navigation and typeahead do not commit before Enter', async ({ page }, testInfo) => { await setup(page) await page.locator('.art-control-playAndPause').focus() for (let attempt = 0; attempt < 8 && !await trigger(page).evaluate(element => document.activeElement === element); attempt++) await page.keyboard.press('Tab') await expect(trigger(page)).toBeFocused() await expect(trigger(page)).toHaveAttribute('aria-haspopup', 'listbox') await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false') await expect(list(page)).toHaveAttribute('role', 'listbox') await expect(list(page)).toHaveAttribute('aria-label', 'Quality') await expect(list(page)).toBeHidden() await page.keyboard.press('ArrowDown') await expect(item(page, 1)).toBeFocused() await expect(item(page, 1)).toHaveAttribute('aria-selected', 'true') await page.keyboard.press('ArrowDown') await expect(item(page, 2)).toBeFocused() await expect(item(page, 2)).toHaveAttribute('aria-selected', 'false') await page.keyboard.press('f') await expect(item(page, 5)).toBeFocused() await page.keyboard.press('Home') await expect(item(page, 0)).toBeFocused() await page.keyboard.press('End') await expect(item(page, 7)).toBeFocused() expect(await page.evaluate(() => window.selectorCalls)).toEqual([]) expect(await page.evaluate(() => window.art.currentTime)).toBe(0) expect(await page.evaluate(() => window.art.volume)).toBe(0.7) await page.locator('.player').screenshot({ path: testInfo.outputPath('selector-keyboard.png') }) await page.keyboard.press('Enter') await expect(trigger(page)).toBeFocused() await expect(trigger(page)).toHaveText('Hotel') await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false') await expect(list(page)).toBeHidden() await expect(item(page, 7)).toHaveAttribute('aria-selected', 'true') await expect(item(page, 1)).toHaveAttribute('aria-selected', 'false') expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Hotel', true, 'click']]) expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([]) expect(await page.locator('.art-control-keyboard-selector').getAttribute('role')).toBeNull() }) test('candidate: selector Space commits once and Escape closes before web fullscreen exits', async ({ page }) => { await setup(page) await page.evaluate(() => { window.art.fullscreenWeb = true }) await trigger(page).focus() await page.keyboard.down('Space') await page.keyboard.down('Space') await expect(list(page)).toBeHidden() await page.keyboard.up('Space') await expect(item(page, 1)).toBeFocused() await page.keyboard.press('ArrowDown') await page.keyboard.down('Space') await page.keyboard.down('Space') expect(await page.evaluate(() => window.selectorCalls)).toEqual([]) await page.keyboard.up('Space') await expect(trigger(page)).toBeFocused() expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Charlie', true, 'click']]) expect(await page.evaluate(() => window.selectorClicks)).toEqual([[true, true, 'click'], [true, true, 'click']]) await page.keyboard.press('ArrowDown') await expect(item(page, 2)).toBeFocused() await page.keyboard.press('Escape') await expect(trigger(page)).toBeFocused() await expect(list(page)).toBeHidden() expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true) expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([]) await page.keyboard.press('Tab') await expect(trigger(page)).not.toBeFocused() await expect(list(page)).toBeHidden() }) test('candidate: keyboard selector retains asynchronous choice ownership and disposal', async ({ page }) => { await setup(page, 'async') await trigger(page).focus() await page.keyboard.press('ArrowDown') await page.keyboard.press('Home') await page.keyboard.press('Enter') await page.keyboard.press('ArrowDown') await page.keyboard.press('End') await page.keyboard.press('Enter') await page.evaluate(() => { window.pendingSelections.Hotel('Latest') window.pendingSelections.Alpha('Obsolete') }) await expect(trigger(page)).toHaveText('Latest') await page.keyboard.press('ArrowDown') await page.keyboard.press('Home') await page.keyboard.press('Enter') await page.evaluate(() => { window.detachedValue = window.art.controls['keyboard-selector'].querySelector('.art-selector-value') window.art.controls.remove('keyboard-selector') window.pendingSelections.Alpha('After removal') }) expect(await page.evaluate(() => window.detachedValue.textContent)).toBe('Alpha') expect(await page.evaluate(() => { const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) window.detachedValue.dispatchEvent(event) return event.defaultPrevented })).toBe(false) }) test('candidate: pointer hover and selector selection retain their existing click behavior', async ({ page }) => { await setup(page) await page.locator('#play').focus() await trigger(page).hover() await expect(list(page)).toBeVisible() await expect(trigger(page)).toHaveAttribute('aria-expanded', 'true') await item(page, 0).click() await expect(trigger(page)).toHaveText('Alpha') await expect(item(page, 0)).toHaveAttribute('aria-selected', 'true') expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Alpha', true, 'click']]) await trigger(page).focus() await page.keyboard.press('ArrowDown') await page.keyboard.press('Escape') await expect(list(page)).toBeHidden() await page.locator('#play').hover() await trigger(page).hover() await expect(list(page)).toBeVisible() await expect(trigger(page)).toHaveAttribute('aria-expanded', 'true') }) test('candidate: custom native controls retain their semantics and typing inside selector content', async ({ page }) => { await setup(page, 'complex') await trigger(page).focus() await page.keyboard.press('ArrowDown') await expect(page.locator('#nested-option')).toBeFocused() await expect(list(page)).toHaveAttribute('role', 'group') expect(await item(page, 0).getAttribute('role')).toBeNull() await page.keyboard.press('Space') expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'native', true, 'click']]) const currentButton = trigger(page).locator('#nested-option') await expect(currentButton).toBeFocused() expect(await trigger(page).getAttribute('role')).toBeNull() expect(await currentButton.getAttribute('aria-haspopup')).toBeNull() expect(await page.evaluate(() => window.art.video.paused)).toBe(true) await page.keyboard.press('ArrowDown') await expect(list(page).locator('#nested-option')).toBeFocused() await page.locator('.art-selector-list #nested-input').focus() await page.keyboard.press('End') await page.keyboard.type(' text') await expect(page.locator('.art-selector-list #nested-input')).toHaveValue('Editable text') await page.keyboard.press('Escape') await expect(list(page)).toBeHidden() expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([]) }) test('candidate: empty selectors are disabled and selection callbacks keep explicitly moved focus', async ({ page }) => { await setup(page, 'empty') await trigger(page).focus() await expect(trigger(page)).toHaveAttribute('aria-disabled', 'true') await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter') await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false') await expect(trigger(page)).toBeFocused() expect(await page.evaluate(() => window.selectorClicks)).toEqual([]) await setup(page, 'external-focus') await trigger(page).focus() await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter') await expect(page.locator('#play')).toBeFocused() await expect(list(page)).toBeHidden() }) test('candidate: typeahead accepts prefixes, resets after navigation and leaves modified input alone', async ({ page }) => { await setup(page) await trigger(page).focus() await page.keyboard.press('ArrowDown') await page.keyboard.type('ho') await expect(item(page, 7)).toBeFocused() await page.keyboard.press('Home') await page.keyboard.press('f') await expect(item(page, 5)).toBeFocused() await page.keyboard.press('ArrowUp') await page.keyboard.press('h') await expect(item(page, 7)).toBeFocused() await page.keyboard.press('Control+Home') await expect(item(page, 7)).toBeFocused() expect(await page.evaluate(() => window.selectorCalls)).toEqual([]) }) test('candidate: native trigger markup remains a single button and can open the existing popup', async ({ page }) => { await setup(page) await page.evaluate(() => { window.art.controls.update({ name: 'keyboard-selector', html: '' }) }) const button = page.locator('#custom-trigger') await expect(button).toHaveAttribute('aria-haspopup', 'listbox') expect(await trigger(page).getAttribute('role')).toBeNull() await button.focus() await page.keyboard.press('Space') await expect(item(page, 1)).toBeFocused() expect(await page.evaluate(() => window.selectorClicks)).toEqual([[true, true, 'click']]) expect(await page.evaluate(() => window.art.video.paused)).toBe(true) await page.keyboard.press('Escape') await expect(button).toBeFocused() await expect(list(page)).toBeHidden() }) test('candidate: a dismissed hovered selector releases the next Escape to fullscreen', async ({ page }) => { await setup(page) await page.evaluate(() => { window.art.fullscreenWeb = true }) await trigger(page).hover() await trigger(page).focus() await page.keyboard.press('ArrowDown') await page.keyboard.press('Escape') await expect(list(page)).toBeHidden() expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true) await page.keyboard.press('Escape') await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false) }) test('candidate: hovering an empty selector does not announce an expanded popup', async ({ page }) => { await setup(page, 'empty') await trigger(page).hover() await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false') await expect(trigger(page)).toHaveAttribute('aria-disabled', 'true') })