mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): keep Video.js vendor-chrome shortcuts after a mouse click on a control (#1523)
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With Video.js's own controls, Chromium leaves a clicked control-bar button focused, and a focused Video.js component captures the keyboard entirely, so after clicking fullscreen Space left fullscreen instead of pausing and the seek, volume and mute keys did nothing until the user clicked the video. ArtPlayer and the native HTML5 controls were verified unaffected. The legacy Video.js chrome now releases the focus a pointer interaction leaves on a control (vjs-pointer-focus-release.ts). The release is scoped to the .vjs-control-bar and pointer-attributed, and runs on both focusin (focus landing on a control, e.g. a menu handing focus to its button) and click (a control clicked while already focused, which fires no focusin); keyboard Tab focus and modal-dialog focus traps are preserved. The eligibility helper is shared with ControlsSurface via pointer-focus-release.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
5a11b82eaf
commit
fe3c86394c
13 files changed
+902
-145
No files matched your search
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
blurFocusedControl,
|
||||
clickPointerOrigin,
|
||||
} from './pointer-focus-release';
|
||||
|
||||
export interface ControlsSurfaceHandlers {
|
||||
/** Reveal the controls (pointer move / enter / click on the surface). */
|
||||
reveal: () => void;
|
||||
@@ -36,14 +41,6 @@ const POINTER_ATTRIBUTION_WINDOW_MS = 1000;
|
||||
|
||||
const INTERACTIVE_SELECTOR = 'button, input, [role="slider"]';
|
||||
|
||||
/**
|
||||
* Controls whose pointer-originated focus is released once the click that
|
||||
* produced it completes. Text entry is deliberately absent: a click into a
|
||||
* field must not end typing. The bar holds none today.
|
||||
*/
|
||||
const POINTER_FOCUS_RELEASE_SELECTOR =
|
||||
'button, input[type="range"], [role="slider"]';
|
||||
|
||||
interface PointerPress {
|
||||
at: number;
|
||||
target: EventTarget | null;
|
||||
@@ -221,9 +218,9 @@ export class ControlsSurface {
|
||||
* key press (`onDocumentKeyDown`).
|
||||
*/
|
||||
wasPointerClick(event: MouseEvent): boolean {
|
||||
const pointerType = (event as Partial<PointerEvent>).pointerType;
|
||||
if (typeof pointerType === 'string') {
|
||||
return pointerType !== '';
|
||||
const origin = clickPointerOrigin(event);
|
||||
if (origin !== null) {
|
||||
return origin;
|
||||
}
|
||||
const press = this.lastPointerDownClick;
|
||||
this.lastPointerDownClick = null;
|
||||
@@ -252,23 +249,16 @@ export class ControlsSurface {
|
||||
* a later Tab continues from it exactly as if it were still focused.
|
||||
* Handlers of the resulting `focusout` can recognize the release through
|
||||
* {@link wasPointerFocusRelease}. Returns whether focus was released.
|
||||
* The eligibility rules live in `pointer-focus-release.ts`, shared with
|
||||
* the vendor-chrome Video.js player.
|
||||
*/
|
||||
releasePointerFocus(root: HTMLElement): boolean {
|
||||
const active = root.ownerDocument.activeElement;
|
||||
if (
|
||||
!(active instanceof HTMLElement) ||
|
||||
!root.contains(active) ||
|
||||
!active.matches(POINTER_FOCUS_RELEASE_SELECTOR)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.pointerFocusRelease = true;
|
||||
try {
|
||||
active.blur();
|
||||
return blurFocusedControl(root);
|
||||
} finally {
|
||||
this.pointerFocusRelease = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './controls-fullscreen';
|
||||
export * from './controls-shortcuts';
|
||||
export * from './legacy-player-shortcuts';
|
||||
export * from './controls-surface';
|
||||
export * from './pointer-focus-release';
|
||||
export * from './controls-timeline';
|
||||
export * from './controls-view-model';
|
||||
export * from './controls-visibility';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
blurFocusedControl,
|
||||
clickPointerOrigin,
|
||||
} from './pointer-focus-release';
|
||||
|
||||
/** A click as an engine dispatches it; `pointerType` only when given. */
|
||||
function click(pointerType?: string): MouseEvent {
|
||||
const event = new MouseEvent('click', { bubbles: true });
|
||||
if (pointerType !== undefined) {
|
||||
Object.defineProperty(event, 'pointerType', { value: pointerType });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('clickPointerOrigin', () => {
|
||||
it('reports a pointer for mouse, touch, and pen clicks', () => {
|
||||
expect(clickPointerOrigin(click('mouse'))).toBe(true);
|
||||
expect(clickPointerOrigin(click('touch'))).toBe(true);
|
||||
expect(clickPointerOrigin(click('pen'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports keyboard or script activation for an empty pointer type', () => {
|
||||
expect(clickPointerOrigin(click(''))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unknown for a legacy click without a pointer type', () => {
|
||||
expect(clickPointerOrigin(click())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('blurFocusedControl', () => {
|
||||
let root: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it('blurs a focused button inside the root', () => {
|
||||
const button = document.createElement('button');
|
||||
root.appendChild(button);
|
||||
button.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
});
|
||||
|
||||
it('blurs a focused range input and ARIA slider by default', () => {
|
||||
const range = document.createElement('input');
|
||||
range.type = 'range';
|
||||
const slider = document.createElement('div');
|
||||
slider.setAttribute('role', 'slider');
|
||||
slider.tabIndex = 0;
|
||||
root.append(range, slider);
|
||||
|
||||
range.focus();
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
slider.focus();
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('leaves focus outside the root alone', () => {
|
||||
const outside = document.createElement('button');
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it('does nothing when nothing is focused', () => {
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus in text entry', () => {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'text';
|
||||
root.appendChild(field);
|
||||
field.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(field);
|
||||
});
|
||||
|
||||
it('honors a custom selector', () => {
|
||||
const clickable = document.createElement('div');
|
||||
clickable.setAttribute('role', 'button');
|
||||
clickable.tabIndex = 0;
|
||||
root.appendChild(clickable);
|
||||
clickable.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(clickable);
|
||||
expect(blurFocusedControl(root, { selector: '[role="button"]' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(document.activeElement).not.toBe(clickable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Controls whose pointer-originated focus is released once the click that
|
||||
* produced it completes. Text entry is deliberately absent: a click into a
|
||||
* field must not end typing. Neither the shared controls bar nor the vendor
|
||||
* chrome holds one today.
|
||||
*/
|
||||
export const POINTER_FOCUS_RELEASE_SELECTOR =
|
||||
'button, input[type="range"], [role="slider"]';
|
||||
|
||||
export interface BlurFocusedControlOptions {
|
||||
/** Controls eligible for release; defaults to the shared selector. */
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a click's own `pointerType` says about its origin: `true` for a
|
||||
* pointer (mouse, touch, pen); `false` for keyboard activation or script,
|
||||
* since current engines dispatch Enter/Space on a focused button and
|
||||
* `element.click()` as a PointerEvent with an empty `pointerType`; `null`
|
||||
* for a legacy MouseEvent click that carries no pointer type at all, which
|
||||
* the caller must attribute some other way or leave alone.
|
||||
*/
|
||||
export function clickPointerOrigin(event: MouseEvent): boolean | null {
|
||||
const pointerType = (event as Partial<PointerEvent>).pointerType;
|
||||
return typeof pointerType === 'string' ? pointerType !== '' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blur the focused control inside `root` when it is eligible. Chromium
|
||||
* focuses a clicked `<button>`, and a focused control captures the keyboard:
|
||||
* Space and Enter activate it again and the playback shortcuts yield to it.
|
||||
* Focus a pointer click left behind was never the keyboard's, so it is
|
||||
* dropped once the click completes; Chromium keeps its sequential focus
|
||||
* navigation starting point at the blurred control, so a later Tab
|
||||
* continues from it exactly as if it were still focused. Returns whether
|
||||
* focus was released.
|
||||
*/
|
||||
export function blurFocusedControl(
|
||||
root: HTMLElement,
|
||||
options: BlurFocusedControlOptions = {}
|
||||
): boolean {
|
||||
const active = root.ownerDocument.activeElement;
|
||||
if (
|
||||
!(active instanceof HTMLElement) ||
|
||||
!root.contains(active) ||
|
||||
!active.matches(options.selector ?? POINTER_FOCUS_RELEASE_SELECTOR)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
active.blur();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls/web-player-controls.flag';
|
||||
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
|
||||
import type { VideoJsPlayer } from './vjs-player.types';
|
||||
|
||||
const videoJsMock = jest.fn();
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({ default: videoJsMock }));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
Events: { ERROR: 'error' },
|
||||
createPlayer: jest.fn(),
|
||||
isSupported: jest.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
// Real `attachVjsPointerFocusRelease` is exercised through the shell so the
|
||||
// test proves both the ngAfterViewInit wiring (the view child must be
|
||||
// resolved) and that the shared-controls guard and teardown behave.
|
||||
describe('VjsPlayerComponent pointer focus release wiring', () => {
|
||||
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
|
||||
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VjsPlayerComponent } = await import('./vjs-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
videoJsMock.mockReset().mockImplementation(() => createPlayerMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
const mount = async (sharedControls: boolean) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VjsPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: WEB_PLAYER_SHARED_CONTROLS,
|
||||
useValue: sharedControls,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(VjsPlayerComponent);
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/movie.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement.querySelector(
|
||||
'.vjs-player-shell'
|
||||
) as HTMLElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* A control bar with a button — the mocked player renders neither, and
|
||||
* the release is scoped to `.vjs-control-bar`, so the test owns both.
|
||||
*/
|
||||
const addControl = (shell: HTMLElement) => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'vjs-control-bar';
|
||||
const button = document.createElement('button');
|
||||
bar.appendChild(button);
|
||||
shell.appendChild(bar);
|
||||
return button;
|
||||
};
|
||||
|
||||
const pressAndFocus = (shell: HTMLElement, control: HTMLElement) => {
|
||||
shell.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
||||
control.focus();
|
||||
};
|
||||
|
||||
it('releases the focus a pointer press leaves on a control (attach reached the resolved shell)', async () => {
|
||||
const shell = await mount(false);
|
||||
const control = addControl(shell);
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).not.toBe(control);
|
||||
});
|
||||
|
||||
it('leaves the control focused after the component is destroyed', async () => {
|
||||
const shell = await mount(false);
|
||||
const control = addControl(shell);
|
||||
fixture.destroy();
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
|
||||
it('does not attach when shared controls own the surface', async () => {
|
||||
const shell = await mount(true);
|
||||
const control = addControl(shell);
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
});
|
||||
|
||||
function createPlayerMock(): VideoJsPlayer {
|
||||
const player = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
ready: jest.fn(),
|
||||
getChild: jest.fn(() => null),
|
||||
audioTracks: jest.fn(() => null),
|
||||
textTracks: jest.fn(() => null),
|
||||
currentTime: jest.fn(() => 0),
|
||||
duration: jest.fn(() => 0),
|
||||
error: jest.fn(() => null),
|
||||
paused: jest.fn(() => true),
|
||||
play: jest.fn(() => Promise.resolve()),
|
||||
pause: jest.fn(),
|
||||
volume: jest.fn(() => 1),
|
||||
muted: jest.fn(() => false),
|
||||
src: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
isFullscreen: jest.fn(() => false),
|
||||
requestFullscreen: jest.fn(),
|
||||
exitFullscreen: jest.fn(),
|
||||
controlBar: { getChild: jest.fn(() => null) },
|
||||
};
|
||||
return player as unknown as VideoJsPlayer;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks';
|
||||
import { VjsLegacyTracks } from './vjs-legacy-tracks';
|
||||
import { VjsMpegTsSession } from './vjs-mpegts-session';
|
||||
import { attachVjsLegacyShortcuts } from './vjs-legacy-shortcuts';
|
||||
import { attachVjsPointerFocusRelease } from './vjs-pointer-focus-release';
|
||||
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
|
||||
import {
|
||||
createVjsPlayerOptions,
|
||||
@@ -119,6 +120,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private legacyTracks: VjsLegacyTracks | null = null;
|
||||
/** Keyboard shortcuts for the legacy chrome; null with shared controls. */
|
||||
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
|
||||
/** Pointer focus release for the legacy chrome; null with shared controls. */
|
||||
private detachPointerFocusRelease: (() => void) | null = null;
|
||||
private desiredSource: VideoPlayerSource | null = null;
|
||||
private readyHandled = false;
|
||||
private destroyed = false;
|
||||
@@ -151,6 +154,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
isAvailable: () => this.interactionEnabled(),
|
||||
isLive: () => this.options().isLive !== false,
|
||||
});
|
||||
const shell = this.playerRoot()?.nativeElement;
|
||||
if (shell) {
|
||||
this.detachPointerFocusRelease =
|
||||
attachVjsPointerFocusRelease(shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +209,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.destroyed = true;
|
||||
this.legacyShortcuts?.detach();
|
||||
this.legacyShortcuts = null;
|
||||
this.detachPointerFocusRelease?.();
|
||||
this.detachPointerFocusRelease = null;
|
||||
this.resetCoordinator.destroy();
|
||||
this.controlsBridge?.destroy();
|
||||
this.controlsBridge = null;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { attachVjsPointerFocusRelease } from './vjs-pointer-focus-release';
|
||||
|
||||
describe('attachVjsPointerFocusRelease', () => {
|
||||
let root: HTMLElement;
|
||||
let fullscreen: HTMLButtonElement;
|
||||
let seekBar: HTMLElement;
|
||||
let menuButton: HTMLButtonElement;
|
||||
let menuItem: HTMLElement;
|
||||
let modalResetButton: HTMLButtonElement;
|
||||
let detach: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div');
|
||||
root.className = 'vjs-player-shell';
|
||||
// The Video.js 8 layout that matters here: the control bar holds the
|
||||
// focusable chrome (buttons, sliders, a menu button whose popup items
|
||||
// are focused by `MenuButton.pressButton()`); the caption-settings
|
||||
// dialog is a modal *sibling* of the control bar, under `.video-js`.
|
||||
root.innerHTML = `
|
||||
<div class="video-js">
|
||||
<div class="vjs-control-bar">
|
||||
<div class="vjs-progress-control vjs-control">
|
||||
<div class="vjs-progress-holder vjs-slider" role="slider" tabindex="0"></div>
|
||||
</div>
|
||||
<div class="vjs-playback-rate vjs-menu-button-popup vjs-control">
|
||||
<button class="vjs-playback-rate vjs-menu-button" type="button" aria-expanded="false"></button>
|
||||
<div class="vjs-menu">
|
||||
<ul class="vjs-menu-content" role="menu">
|
||||
<li class="vjs-menu-item" role="menuitemradio" tabindex="-1"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="vjs-fullscreen-control vjs-control vjs-button" type="button"></button>
|
||||
</div>
|
||||
<div class="vjs-text-track-settings vjs-modal-dialog" role="dialog">
|
||||
<button class="vjs-default-button" type="button"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(root);
|
||||
fullscreen = root.querySelector(
|
||||
'.vjs-fullscreen-control'
|
||||
) as HTMLButtonElement;
|
||||
seekBar = root.querySelector('.vjs-progress-holder') as HTMLElement;
|
||||
menuButton = root.querySelector(
|
||||
'button.vjs-menu-button'
|
||||
) as HTMLButtonElement;
|
||||
menuItem = root.querySelector('.vjs-menu-item') as HTMLElement;
|
||||
modalResetButton = root.querySelector(
|
||||
'.vjs-text-track-settings .vjs-default-button'
|
||||
) as HTMLButtonElement;
|
||||
detach = attachVjsPointerFocusRelease(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
detach();
|
||||
root.remove();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** A pointer press as the pane sees it (jsdom lacks PointerEvent). */
|
||||
const pointerDown = (target: EventTarget = root) => {
|
||||
target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
||||
};
|
||||
|
||||
const keyDown = (target: EventTarget = document.body) => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const click = (target: EventTarget) => {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
);
|
||||
};
|
||||
|
||||
it('releases the focus that lands on a control-bar button after a pointer press', () => {
|
||||
pointerDown();
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('releases a control-bar control clicked while it was already focused', () => {
|
||||
// Tab focuses it (kept — no pointer press yet), then a mouse click on
|
||||
// the same control moves no focus and fires no focusin, so the click
|
||||
// is the only signal.
|
||||
fullscreen.focus();
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
|
||||
pointerDown();
|
||||
click(fullscreen);
|
||||
|
||||
expect(document.activeElement).not.toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('keeps an already-focused control the keyboard activates', () => {
|
||||
// Space/Enter on a focused button fires a click with no preceding
|
||||
// pointerdown, so the focus is kept.
|
||||
fullscreen.focus();
|
||||
|
||||
click(fullscreen);
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('leaves a modal dialog button focused when it is clicked', () => {
|
||||
pointerDown(modalResetButton);
|
||||
modalResetButton.focus();
|
||||
// Even the click path is scoped to the control bar.
|
||||
click(modalResetButton);
|
||||
|
||||
expect(document.activeElement).toBe(modalResetButton);
|
||||
});
|
||||
|
||||
it('releases a control-bar slider', () => {
|
||||
pointerDown();
|
||||
seekBar.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(seekBar);
|
||||
});
|
||||
|
||||
it('leaves a modal dialog button focused so its focus trap survives', () => {
|
||||
// The caption-settings dialog keeps its own Escape/Tab handling; its
|
||||
// Reset button sits outside the control bar and must not be released.
|
||||
pointerDown(modalResetButton);
|
||||
modalResetButton.focus();
|
||||
|
||||
expect(document.activeElement).toBe(modalResetButton);
|
||||
});
|
||||
|
||||
it('keeps focus that no pointer press preceded (keyboard navigation)', () => {
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('keeps focus once a key press has ended the pointer attribution', () => {
|
||||
// The key that follows a release is pressed while focus rests on
|
||||
// body, outside the shell, so the clear must not depend on the shell.
|
||||
pointerDown();
|
||||
keyDown(document);
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('releases the menu button Video.js focuses after an item is chosen', () => {
|
||||
// Choosing an item: the press lands on the item, which is not
|
||||
// eligible, then Video.js moves focus to the now-collapsed button.
|
||||
pointerDown(menuItem);
|
||||
menuItem.focus();
|
||||
expect(document.activeElement).toBe(menuItem);
|
||||
|
||||
menuButton.setAttribute('aria-expanded', 'false');
|
||||
menuButton.focus();
|
||||
expect(document.activeElement).not.toBe(menuButton);
|
||||
});
|
||||
|
||||
it('releases an expanded menu button so Space works after a click closes it', () => {
|
||||
// Toggling an open menu shut with a pointer moves focus item -> button
|
||||
// while the button is still expanded; the button then swallows keys
|
||||
// until released. It is not exempt: the popup is navigated through its
|
||||
// item, so dropping the button focus never disturbs an open menu.
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
pointerDown();
|
||||
menuButton.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(menuButton);
|
||||
});
|
||||
|
||||
it('keeps a focused menu item so arrow navigation survives', () => {
|
||||
pointerDown();
|
||||
menuItem.focus();
|
||||
|
||||
expect(document.activeElement).toBe(menuItem);
|
||||
});
|
||||
|
||||
it('forgets a pointer press after the attribution window', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
pointerDown();
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:01.500Z'));
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('stops releasing after detach', () => {
|
||||
detach();
|
||||
pointerDown();
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { blurFocusedControl } from '../player-controls';
|
||||
|
||||
/**
|
||||
* How long after a pointer press a focus that lands on a control is still
|
||||
* attributed to that press. A menu selection moves focus to the menu button
|
||||
* a tick or two after the click, so the window covers that gap while a later
|
||||
* keydown ends it (Tab focus must not be attributed to a stale press).
|
||||
*/
|
||||
const POINTER_ATTRIBUTION_WINDOW_MS = 1000;
|
||||
|
||||
/** The persistent chrome the release is scoped to (see the doc comment). */
|
||||
const VJS_CONTROL_BAR_SELECTOR = '.vjs-control-bar';
|
||||
|
||||
/**
|
||||
* Control-bar focusables that capture the keyboard: `<button>`s (including
|
||||
* menu buttons), `ClickableComponent` divs (`role="button"`) and sliders
|
||||
* (`role="slider"`). Menu items are `role="menuitem*"` and are deliberately
|
||||
* absent — while a menu is open its item, not its button, owns arrow-key
|
||||
* navigation, so releasing the button never disturbs it.
|
||||
*/
|
||||
const VJS_RELEASE_SELECTOR = 'button, [role="button"], [role="slider"]';
|
||||
|
||||
/**
|
||||
* Vendor-chrome counterpart of the shared controls' pointer focus release
|
||||
* (`ControlsSurface.releasePointerFocus`), attached to the player shell of
|
||||
* the preference-off Video.js player. Chromium focuses a clicked control, and
|
||||
* a focused Video.js component captures the keyboard entirely:
|
||||
* `Component.handleKeyDown` stops the propagation of every key and
|
||||
* `ClickableComponent` turns Space and Enter into a click, so after a mouse
|
||||
* click on the fullscreen button Space left fullscreen instead of pausing and
|
||||
* `LegacyPlayerShortcuts` on the document never saw a key.
|
||||
*
|
||||
* The release is driven mainly by the focus landing, not the click: choosing
|
||||
* a menu item moves focus to the menu button a tick after the click (Video.js
|
||||
* `MenuItem.handleTapClick`), and the selection click never bubbles to this
|
||||
* root, so a click handler alone would be both too early and unreached.
|
||||
* A `focusin` on an eligible control is released when it is attributable to a
|
||||
* recent pointer press inside the shell — a `pointerdown` within the window,
|
||||
* not yet ended by a keydown anywhere — so keyboard `Tab` focus is preserved
|
||||
* (the keydown listener is on the document because the key that follows a
|
||||
* release is pressed while focus sits on `body`, outside the shell). A
|
||||
* `click` runs the same release, because clicking a control that was already
|
||||
* focused (Tab, then a mouse click on it) moves no focus and fires no
|
||||
* `focusin`; a keyboard-activation click carries no `pointerdown`, so
|
||||
* attribution keeps that focus.
|
||||
*
|
||||
* The release is scoped to the `.vjs-control-bar`: that persistent chrome is
|
||||
* what hands keys back to the document, while the player's other focusable
|
||||
* surfaces manage their own focus and must keep it. In particular the
|
||||
* caption-settings dialog (`.vjs-text-track-settings`, a modal sibling of the
|
||||
* control bar) traps focus for its Escape/Tab handling — blurring its Reset
|
||||
* button would let keys leak to the document behind the open dialog. Menu
|
||||
* buttons live in the control bar and are not exempt: a Video.js popup is
|
||||
* navigated through its focused item, not its button, so releasing the button
|
||||
* never breaks it. Opening a menu focuses the item (not eligible), and the
|
||||
* button focus a pointer press moves through — the transient press on open,
|
||||
* item selection, and toggling an open menu shut — is released, which is what
|
||||
* lets Space work again after a menu is dismissed by clicking its button a
|
||||
* second time. Returns the detach function.
|
||||
*/
|
||||
export function attachVjsPointerFocusRelease(root: HTMLElement): () => void {
|
||||
const doc = root.ownerDocument;
|
||||
let lastPointerDownAt: number | null = null;
|
||||
|
||||
// Only presses inside the shell count: a click elsewhere on the page must
|
||||
// not attribute a later `Tab` into the controls.
|
||||
const onPointerDown = () => {
|
||||
lastPointerDownAt = Date.now();
|
||||
};
|
||||
// A key press ends pointer attribution: focus that moves after it was
|
||||
// moved by the keyboard, whatever the last press hit (mirrors
|
||||
// `ControlsSurface`). It is on the document because the key that follows
|
||||
// a release is pressed while focus rests on `body`, outside the shell.
|
||||
const onKeyDown = () => {
|
||||
lastPointerDownAt = null;
|
||||
};
|
||||
|
||||
const attributed = () =>
|
||||
lastPointerDownAt !== null &&
|
||||
Date.now() - lastPointerDownAt <= POINTER_ATTRIBUTION_WINDOW_MS;
|
||||
|
||||
// Passing the control bar (not the shell) as the root scopes the release
|
||||
// to it, so focus inside a modal dialog is left alone.
|
||||
const releaseControlBarFocus = () => {
|
||||
const controlBar = root.querySelector(VJS_CONTROL_BAR_SELECTOR);
|
||||
if (controlBar instanceof HTMLElement) {
|
||||
blurFocusedControl(controlBar, { selector: VJS_RELEASE_SELECTOR });
|
||||
}
|
||||
};
|
||||
|
||||
// Focus that moves onto a control (a pointer focusing it, or a menu
|
||||
// handing focus to its button) is the common path.
|
||||
const onFocusIn = () => {
|
||||
if (attributed()) {
|
||||
releaseControlBarFocus();
|
||||
}
|
||||
};
|
||||
// Clicking a control that was already focused (Tab, then a mouse click on
|
||||
// the same control) moves no focus and fires no `focusin`, so the click
|
||||
// is the only signal; a keyboard-activation click carries no preceding
|
||||
// `pointerdown`, so `attributed()` keeps that focus.
|
||||
const onClick = () => {
|
||||
if (attributed()) {
|
||||
releaseControlBarFocus();
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
doc.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
root.addEventListener('focusin', onFocusIn);
|
||||
root.addEventListener('click', onClick);
|
||||
return () => {
|
||||
root.removeEventListener('pointerdown', onPointerDown, {
|
||||
capture: true,
|
||||
});
|
||||
doc.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
root.removeEventListener('focusin', onFocusIn);
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user