mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): keep playback shortcuts after a mouse click on a bar button (#1516)
Chromium focuses a clicked <button>, and a focused control captures the keyboard: Space and Enter activate it again, and ControlsShortcuts yields to any interactive element in the key's path. After a click on the fullscreen button, Space left fullscreen instead of pausing and the seek, volume and mute keys did nothing until a click on the video took focus away. Follow-up to #1512, which stopped that focus from pinning the bar but left it on the button. A completed pointer click now releases the focus it left on the control (onBarClick -> ControlsSurface.releasePointerFocus). The click is attributed by its pointerType (empty for Enter/Space activation and element.click()), with the legacy MouseEvent fallback answered once per recorded press, so keyboard activation keeps focus where Tab put it. Only buttons and range sliders are released. Chromium keeps its sequential-focus starting point at the blurred control, so a later Tab continues from it. The release dispatches a focusout while the pointer still rests on the control, so the volume anchor ignores it instead of closing the popover under the hovering mouse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
90d26d499f
commit
308ed9cb41
12 files changed
+548
-26
No files matched your search
@@ -180,6 +180,37 @@ describe('ControlsShortcuts', () => {
|
||||
menuItem.remove();
|
||||
});
|
||||
|
||||
it('yields to a focused button only while it holds focus', () => {
|
||||
const button = document.createElement('button');
|
||||
document.body.append(button);
|
||||
button.focus();
|
||||
|
||||
// The key reaches the focused button first: its activation owns
|
||||
// Space, whatever the playback state.
|
||||
button.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
|
||||
// Releasing the focus a pointer click left behind hands Space back
|
||||
// to playback (the controls bar does this on a completed click).
|
||||
button.blur();
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.body.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
|
||||
button.remove();
|
||||
});
|
||||
|
||||
it('ignores modified playback shortcuts without preventing their defaults', () => {
|
||||
expect(dispatchKey('k', { metaKey: true })).toBe(false);
|
||||
expect(dispatchKey('f', { ctrlKey: true })).toBe(false);
|
||||
|
||||
@@ -352,4 +352,144 @@ describe('ControlsSurface', () => {
|
||||
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer-originated click attribution', () => {
|
||||
let button: HTMLButtonElement;
|
||||
let icon: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
button = document.createElement('button');
|
||||
icon = document.createElement('span');
|
||||
button.appendChild(icon);
|
||||
element.appendChild(button);
|
||||
});
|
||||
|
||||
/** A click as an engine dispatches it; `pointerType` only when given. */
|
||||
const clickOn = (target: EventTarget, pointerType?: string) => {
|
||||
const event = new MouseEvent('click', { bubbles: true });
|
||||
if (pointerType !== undefined) {
|
||||
Object.defineProperty(event, 'pointerType', {
|
||||
value: pointerType,
|
||||
});
|
||||
}
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
return event;
|
||||
};
|
||||
|
||||
it('trusts a non-empty pointer type on the click', () => {
|
||||
expect(surface.wasPointerClick(clickOn(button, 'mouse'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(surface.wasPointerClick(clickOn(button, 'touch'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(surface.wasPointerClick(clickOn(button, 'pen'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads an empty pointer type as keyboard or script activation, even after a press', () => {
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
expect(surface.wasPointerClick(clickOn(button, ''))).toBe(false);
|
||||
});
|
||||
|
||||
it('attributes a legacy click to a recent press inside the clicked element', () => {
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports keyboard activation for a legacy click without a press', () => {
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports keyboard activation when the press landed outside the clicked element', () => {
|
||||
element.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes the press on the first legacy click it is asked about', () => {
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
|
||||
});
|
||||
|
||||
it('answers the focus and the click of one press independently', () => {
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
const focus = new FocusEvent('focusin');
|
||||
Object.defineProperty(focus, 'target', { value: button });
|
||||
expect(surface.wasPointerInteraction(focus)).toBe(true);
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
|
||||
});
|
||||
|
||||
it('discards the press on any key press', () => {
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
|
||||
});
|
||||
|
||||
it('forgets a press after the attribution window', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:01.500Z'));
|
||||
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer focus release', () => {
|
||||
let button: HTMLButtonElement;
|
||||
|
||||
beforeEach(() => {
|
||||
button = document.createElement('button');
|
||||
element.appendChild(button);
|
||||
});
|
||||
|
||||
it('blurs the focused control inside the root and flags its focusout', () => {
|
||||
button.focus();
|
||||
let flagged: boolean | null = null;
|
||||
element.addEventListener('focusout', () => {
|
||||
flagged = surface.wasPointerFocusRelease();
|
||||
});
|
||||
|
||||
expect(surface.releasePointerFocus(element)).toBe(true);
|
||||
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
expect(flagged).toBe(true);
|
||||
expect(surface.wasPointerFocusRelease()).toBe(false);
|
||||
});
|
||||
|
||||
it('releases a focused range slider', () => {
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
element.appendChild(slider);
|
||||
slider.focus();
|
||||
|
||||
expect(surface.releasePointerFocus(element)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(slider);
|
||||
});
|
||||
|
||||
it('leaves focus outside the root alone', () => {
|
||||
const outside = document.createElement('button');
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
|
||||
expect(surface.releasePointerFocus(element)).toBe(false);
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it('does nothing when nothing is focused', () => {
|
||||
expect(surface.releasePointerFocus(element)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus in text entry', () => {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'text';
|
||||
element.appendChild(field);
|
||||
field.focus();
|
||||
|
||||
expect(surface.releasePointerFocus(element)).toBe(false);
|
||||
expect(document.activeElement).toBe(field);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,19 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the surface + document interaction wiring for the controls: reveal on
|
||||
* pointer activity, click-to-pause on the viewport, fullscreen on double-click,
|
||||
@@ -48,14 +61,20 @@ export class ControlsSurface {
|
||||
private surfaceCleanup: (() => void) | null = null;
|
||||
private clickPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastTouchPointerDownAt: number | null = null;
|
||||
private lastPointerDown: { at: number; target: EventTarget | null } | null =
|
||||
null;
|
||||
/** The most recent press, answered once by the focus event it moved. */
|
||||
private lastPointerDown: PointerPress | null = null;
|
||||
/** The same press, answered once by the click it completes into. */
|
||||
private lastPointerDownClick: PointerPress | null = null;
|
||||
/** Set while {@link releasePointerFocus} dispatches its `focusout`. */
|
||||
private pointerFocusRelease = false;
|
||||
|
||||
private readonly onDocumentPointerDown = (event: PointerEvent) => {
|
||||
const now = Date.now();
|
||||
this.lastTouchPointerDownAt =
|
||||
event.pointerType === 'touch' ? now : null;
|
||||
this.lastPointerDown = { at: now, target: event.target };
|
||||
const press: PointerPress = { at: now, target: event.target };
|
||||
this.lastPointerDown = press;
|
||||
this.lastPointerDownClick = press;
|
||||
const path = event.composedPath();
|
||||
if (
|
||||
!this.surface ||
|
||||
@@ -68,11 +87,13 @@ export class ControlsSurface {
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard input ends pointer attribution: focus that moves after a key
|
||||
* press was moved by the keyboard, whatever the last press hit.
|
||||
* Keyboard input ends pointer attribution: focus that moves, or a click
|
||||
* that fires, after a key press was produced by the keyboard, whatever
|
||||
* the last press hit.
|
||||
*/
|
||||
private readonly onDocumentKeyDown = () => {
|
||||
this.lastPointerDown = null;
|
||||
this.lastPointerDownClick = null;
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -189,6 +210,77 @@ export class ControlsSurface {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click completes a pointer press rather than keyboard
|
||||
* activation or script. Click events are PointerEvents in current
|
||||
* engines and carry an empty `pointerType` when nothing pointed
|
||||
* (Enter/Space on a focused button, `element.click()`); a legacy
|
||||
* MouseEvent click is attributed to a recent press that landed inside
|
||||
* the clicked element. A press completes into at most one click, so the
|
||||
* record is discarded on the first click it is asked about and on any
|
||||
* key press (`onDocumentKeyDown`).
|
||||
*/
|
||||
wasPointerClick(event: MouseEvent): boolean {
|
||||
const pointerType = (event as Partial<PointerEvent>).pointerType;
|
||||
if (typeof pointerType === 'string') {
|
||||
return pointerType !== '';
|
||||
}
|
||||
const press = this.lastPointerDownClick;
|
||||
this.lastPointerDownClick = null;
|
||||
if (
|
||||
press === null ||
|
||||
Date.now() - press.at > POINTER_ATTRIBUTION_WINDOW_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const clicked = event.target;
|
||||
return (
|
||||
clicked instanceof Node &&
|
||||
press.target instanceof Node &&
|
||||
clicked.contains(press.target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the focus a pointer click left on a control inside `root`.
|
||||
* 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 (`ControlsShortcuts` ignores keys whose path
|
||||
* holds an interactive element). That focus 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.
|
||||
* Handlers of the resulting `focusout` can recognize the release through
|
||||
* {@link wasPointerFocusRelease}. Returns whether focus was released.
|
||||
*/
|
||||
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();
|
||||
} finally {
|
||||
this.pointerFocusRelease = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `focusout` being dispatched right now comes from
|
||||
* {@link releasePointerFocus}: the pointer is still where it clicked, so
|
||||
* hover-scoped state (the volume popover) must not read it as focus
|
||||
* leaving by keyboard.
|
||||
*/
|
||||
wasPointerFocusRelease(): boolean {
|
||||
return this.pointerFocusRelease;
|
||||
}
|
||||
|
||||
private onClick(event: MouseEvent): void {
|
||||
const touch = this.wasTouchInteraction(event);
|
||||
// Capture before revealing: a touch tap's semantics depend on whether
|
||||
|
||||
@@ -6,6 +6,12 @@ export interface ControlsVolumeInteractionsDeps {
|
||||
menus: ControlsMenuState;
|
||||
/** Pointer-type attribution, provided by the surface. */
|
||||
wasTouchInteraction: (event?: Event) => boolean;
|
||||
/**
|
||||
* Whether the `focusout` being dispatched is the surface releasing the
|
||||
* focus a pointer click left on a control. The pointer is still over the
|
||||
* anchor, so the popover must not schedule its close on it.
|
||||
*/
|
||||
wasPointerFocusRelease: () => boolean;
|
||||
/** Whether the controller advertises the volume capability. */
|
||||
canAdjustVolume: () => boolean;
|
||||
reveal: (options?: { scheduleHide?: boolean }) => void;
|
||||
@@ -30,7 +36,10 @@ export class ControlsVolumeInteractions {
|
||||
}
|
||||
|
||||
hoverLeave(event?: Event): void {
|
||||
if (this.deps.wasTouchInteraction(event)) {
|
||||
if (
|
||||
this.deps.wasTouchInteraction(event) ||
|
||||
this.deps.wasPointerFocusRelease()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.deps.volume.hoverLeave();
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
(pointerenter)="onBarPointerEnter()"
|
||||
(pointerleave)="onBarPointerLeave()"
|
||||
(pointerdown)="onBarPointerDown()"
|
||||
(click)="onBarClick($event)"
|
||||
(keydown)="onBarKeyDown()"
|
||||
(focusin)="onBarFocusIn($event)"
|
||||
(focusout)="onBarFocusOut($event)"
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('PlayerControlsComponent', () => {
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it('does not pin the bar on the focus a mouse click on a control produces', () => {
|
||||
it('does not pin the bar on the focus a mouse press on a control produces', () => {
|
||||
setCapabilities({ fullscreen: true });
|
||||
setState({ status: 'playing' });
|
||||
const surface = document.createElement('div');
|
||||
@@ -337,14 +337,14 @@ describe('PlayerControlsComponent', () => {
|
||||
'[aria-label="Enter fullscreen"]'
|
||||
) as HTMLButtonElement;
|
||||
const icon = button.querySelector('mat-icon') as HTMLElement;
|
||||
// Chromium: pointerdown on the icon, focus moves to the button
|
||||
// (focusin on the bar), then click — all from one mouse press.
|
||||
// Chromium: pointerdown on the icon moves focus to the button
|
||||
// (focusin on the bar). The press is released off the control,
|
||||
// so no click completes it and nothing releases that focus.
|
||||
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
|
||||
icon.dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
button.focus();
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement).toBe(button);
|
||||
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
|
||||
@@ -419,7 +419,7 @@ describe('PlayerControlsComponent', () => {
|
||||
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('releases a keyboard pin when the mouse clicks the focused control itself', () => {
|
||||
it('releases a keyboard pin when the mouse presses the focused control itself', () => {
|
||||
const buttons = Array.from(
|
||||
bar()?.querySelectorAll('button') ?? []
|
||||
) as HTMLButtonElement[];
|
||||
@@ -428,12 +428,13 @@ describe('PlayerControlsComponent', () => {
|
||||
jest.advanceTimersByTime(10000);
|
||||
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
|
||||
|
||||
// Clicking the already focused button produces no focus event.
|
||||
// A press on the already focused button produces no focus event;
|
||||
// released off the control it produces no click either, so the
|
||||
// pointerdown alone must release the pin.
|
||||
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
|
||||
buttons[0].dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
buttons[0].click();
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
|
||||
@@ -462,7 +463,8 @@ describe('PlayerControlsComponent', () => {
|
||||
buttons[1].focus();
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
// ...then Shift+Tab away and Tab back before the press expires.
|
||||
// ...which releases its focus. Tab back into the bar and on to
|
||||
// the clicked control before the press expires.
|
||||
buttons[0].focus();
|
||||
buttons[1].focus();
|
||||
fixture.detectChanges();
|
||||
@@ -518,7 +520,7 @@ describe('PlayerControlsComponent', () => {
|
||||
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('pins the bar when the keyboard operates a mouse-focused control', () => {
|
||||
it('pins the bar when the keyboard operates a control a press left focused', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
status: 'playing',
|
||||
@@ -530,13 +532,13 @@ describe('PlayerControlsComponent', () => {
|
||||
bar()?.querySelectorAll('button') ?? []
|
||||
) as HTMLButtonElement[];
|
||||
|
||||
// Mouse click leaves the control focused but not pinned...
|
||||
// A press released off the control leaves it focused but not
|
||||
// pinned (no click completed, so nothing released the focus)...
|
||||
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
|
||||
buttons[1].dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
buttons[1].focus();
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
|
||||
fixture.detectChanges();
|
||||
@@ -563,6 +565,177 @@ describe('PlayerControlsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer focus release', () => {
|
||||
const bar = () => query('.player-controls__bar') as HTMLElement | null;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
setState({ status: 'playing' });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** A click as Chromium dispatches it: a PointerEvent whose
|
||||
* `pointerType` names the device, or is empty for Enter/Space. */
|
||||
const click = (target: HTMLElement, pointerType: string) => {
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerType', {
|
||||
value: pointerType,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const space = (target: EventTarget) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
|
||||
const mountFullscreenSurface = () => {
|
||||
const surface = document.createElement('div');
|
||||
document.body.appendChild(surface);
|
||||
const requestFullscreen = jest.fn(() => Promise.resolve());
|
||||
(
|
||||
surface as unknown as { requestFullscreen: unknown }
|
||||
).requestFullscreen = requestFullscreen;
|
||||
(
|
||||
document as unknown as { exitFullscreen: unknown }
|
||||
).exitFullscreen = jest.fn(() => Promise.resolve());
|
||||
setCapabilities({ fullscreen: true });
|
||||
fixture.componentRef.setInput('playerSurface', surface);
|
||||
fixture.detectChanges();
|
||||
const button = query(
|
||||
'[aria-label="Enter fullscreen"]'
|
||||
) as HTMLButtonElement;
|
||||
return {
|
||||
button,
|
||||
requestFullscreen,
|
||||
dispose: () => {
|
||||
surface.remove();
|
||||
delete (document as unknown as { exitFullscreen?: unknown })
|
||||
.exitFullscreen;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('releases the focus a mouse click leaves on a button so Space toggles playback again', () => {
|
||||
const { button, requestFullscreen, dispose } =
|
||||
mountFullscreenSurface();
|
||||
const icon = button.querySelector('mat-icon') as HTMLElement;
|
||||
|
||||
// Chromium: pointerdown focuses the button, then the click.
|
||||
icon.dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
button.focus();
|
||||
expect(document.activeElement).toBe(button);
|
||||
click(icon, 'mouse');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
// Space now reaches the playback shortcut instead of activating
|
||||
// the fullscreen button again.
|
||||
expect(space(document.activeElement ?? document.body)).toBe(true);
|
||||
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('releases focus after a legacy MouseEvent click that completes a recorded press', () => {
|
||||
const { button, dispose } = mountFullscreenSurface();
|
||||
button.dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
button.focus();
|
||||
|
||||
// jsdom dispatches a click without a pointerType.
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps focus on a control the keyboard activates', () => {
|
||||
const { button, requestFullscreen, dispose } =
|
||||
mountFullscreenSurface();
|
||||
button.focus();
|
||||
|
||||
// Enter/Space: a synthetic click with an empty pointer type...
|
||||
click(button, '');
|
||||
fixture.detectChanges();
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
// ...and a legacy synthetic click with no press to attribute.
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(2);
|
||||
expect(document.activeElement).toBe(button);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps the volume popover open when a mouse click on the mute button releases its focus', () => {
|
||||
setCapabilities({ volume: true });
|
||||
fixture.detectChanges();
|
||||
const anchor = query(
|
||||
'.player-controls__popover-anchor'
|
||||
) as HTMLElement;
|
||||
const mute = anchor.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
// Hovering the anchor opens the popover; the pointer stays there.
|
||||
anchor.dispatchEvent(new MouseEvent('pointerenter'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
|
||||
|
||||
mute.dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true })
|
||||
);
|
||||
mute.focus();
|
||||
click(mute, 'mouse');
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement).not.toBe(mute);
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
|
||||
expect(bar()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still closes the volume popover when keyboard focus leaves the anchor', () => {
|
||||
setCapabilities({ volume: true });
|
||||
fixture.detectChanges();
|
||||
const anchor = query(
|
||||
'.player-controls__popover-anchor'
|
||||
) as HTMLElement;
|
||||
const mute = anchor.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
mute.focus();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
|
||||
|
||||
const outside = document.createElement('button');
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
fixture.detectChanges();
|
||||
jest.advanceTimersByTime(10000);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.menus.volumeOpen()).toBe(false);
|
||||
outside.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bar-hover state', () => {
|
||||
const bar = () => query('.player-controls__bar') as HTMLElement | null;
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
volume: this.volume,
|
||||
menus: this.menus,
|
||||
wasTouchInteraction: (event) => this.surface.wasTouchInteraction(event),
|
||||
wasPointerFocusRelease: () => this.surface.wasPointerFocusRelease(),
|
||||
canAdjustVolume: () => this.capabilities().volume,
|
||||
reveal: (options) => this.reveal(options),
|
||||
});
|
||||
@@ -387,21 +388,39 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
* A key press that bubbles out of a control inside the bar means the
|
||||
* keyboard is operating that control (Space/Enter on a button, arrows on
|
||||
* a slider): the bar is pinned exactly as if the control had been focused
|
||||
* with Tab. A mouse click leaves its button focused without a pin, and
|
||||
* the key press produces no new focus event, so this is the only place
|
||||
* that hands ownership back to the keyboard.
|
||||
* with Tab. A completed pointer click no longer leaves its control
|
||||
* focused (`onBarClick`), but a press released off the control does,
|
||||
* without a pin, and the key press then produces no new focus event, so
|
||||
* this is the only place that hands ownership back to the keyboard.
|
||||
*/
|
||||
onBarKeyDown(): void {
|
||||
this.barFocused.set(true);
|
||||
this.reveal({ scheduleHide: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer click leaves the clicked control focused (`onBarFocusIn`),
|
||||
* and a focused control captures the keyboard: Space and Enter activate
|
||||
* it again instead of toggling playback, and the playback shortcuts
|
||||
* yield to any interactive element in the key's path — after a click on
|
||||
* the fullscreen button, Space left fullscreen instead of pausing. The
|
||||
* focus was never the keyboard's, so it is released once the click
|
||||
* completes; keyboard activation (an empty click `pointerType`) keeps
|
||||
* focus where Tab put it.
|
||||
*/
|
||||
onBarClick(event: MouseEvent): void {
|
||||
const bar = event.currentTarget;
|
||||
if (bar instanceof HTMLElement && this.surface.wasPointerClick(event)) {
|
||||
this.surface.releasePointerFocus(bar);
|
||||
}
|
||||
}
|
||||
|
||||
onBarFocusIn(event: FocusEvent): void {
|
||||
// Chromium moves focus to a clicked <button>. That focus is a side
|
||||
// effect of the click, not keyboard navigation: it must reveal like
|
||||
// any pointer activity, but never pin the bar open until the next
|
||||
// click on the video takes focus away (issue: fullscreen button left
|
||||
// the controls on screen until a click-to-pause on the viewport).
|
||||
// any pointer activity, but never pin the bar open; `onBarClick`
|
||||
// drops it again once the click completes (issue: fullscreen button
|
||||
// left the controls on screen until a click-to-pause on the viewport).
|
||||
if (this.surface.wasPointerInteraction(event)) {
|
||||
this.barFocused.set(false);
|
||||
this.reveal();
|
||||
|
||||
Reference in new issue
Block a user