fix(packages): ignore gestures on interactive child elements (#1327)

This commit is contained in:
rahim
2026-04-14 02:11:01 -07:00
committed by GitHub
parent f893f0012b
commit b768c29f98
13 changed files with 332 additions and 179 deletions
@@ -372,6 +372,86 @@ describe('regions', () => {
});
});
describe('interactive child filtering', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('does not fire when event originates from a child button', () => {
const container = setup();
const button = document.createElement('button');
container.appendChild(button);
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(button);
vi.advanceTimersByTime(50);
pointerUp(button, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('does not fire when event originates from a child with role="slider"', () => {
const container = setup();
const slider = document.createElement('div');
slider.setAttribute('role', 'slider');
container.appendChild(slider);
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(slider);
vi.advanceTimersByTime(50);
pointerUp(slider, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('does not fire when event originates from a nested child inside an interactive element', () => {
const container = setup();
const button = document.createElement('button');
const icon = document.createElement('span');
button.appendChild(icon);
container.appendChild(button);
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(icon);
vi.advanceTimersByTime(50);
pointerUp(icon, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('fires when event originates from a non-interactive child', () => {
const container = setup();
const overlay = document.createElement('div');
container.appendChild(overlay);
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(overlay);
vi.advanceTimersByTime(50);
pointerUp(overlay, { pointerType: 'mouse', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
it('fires when event targets the container directly', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+1 -15
View File
@@ -1,23 +1,9 @@
import { isEditableTarget, listen, resolveEventTarget } from '@videojs/utils/dom';
import { isEditableTarget, isInteractiveActivation, listen } from '@videojs/utils/dom';
import { toAriaKeyShortcut } from './aria';
import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey';
import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey';
const ACTIVATION_KEYS = new Set([' ', 'Enter']);
/** Whether the event is an activation key on an interactive element (button, slider). */
function isInteractiveActivation(event: KeyboardEvent): boolean {
if (!ACTIVATION_KEYS.has(event.key)) return false;
const target = resolveEventTarget(event);
if (!(target instanceof HTMLElement)) return false;
if (target instanceof HTMLButtonElement) return true;
const role = target.getAttribute('role');
return role === 'button' || role === 'slider';
}
interface HotkeyBinding {
parsed: ParsedHotkeyBinding[];
options: HotkeyOptions;
+1
View File
@@ -1,5 +1,6 @@
export interface UIEvent {
preventDefault(): void;
stopPropagation(): void;
}
export interface UIKeyboardEvent extends UIEvent {
@@ -155,7 +155,7 @@ describe('createPopover', () => {
openOnHover: () => true,
});
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).not.toHaveBeenCalled();
@@ -172,7 +172,7 @@ describe('createPopover', () => {
openOnHover: () => true,
});
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).not.toHaveBeenCalled();
@@ -189,7 +189,7 @@ describe('createPopover', () => {
openOnHover: () => true,
});
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' });
+6
View File
@@ -151,6 +151,10 @@ export function createSlider(options: SliderOptions): SliderApi {
onPointerDown(event) {
if (options.isDisabled()) return;
// The slider fully owns pointer interactions — prevent parent gesture
// coordinators from misinterpreting slider taps as surface gestures.
event.stopPropagation();
// Prevent the browser's default mousedown focus behavior. Without this,
// clicking a non-focusable child (e.g. the track) causes the browser to
// move focus away from the thumb after our programmatic `focus()` call,
@@ -213,6 +217,8 @@ export function createSlider(options: SliderOptions): SliderApi {
onPointerUp(event) {
if (isNull(capturedPointerId)) return;
event.stopPropagation();
const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL);
// Cancel any pending throttled change before the final unthrottled pair.
@@ -53,6 +53,7 @@ function pointerEvent(overrides: Partial<UIPointerEvent> = {}): UIPointerEvent {
pointerType: 'mouse',
buttons: 1,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...overrides,
};
}
@@ -69,6 +70,7 @@ function keyboardEvent(key: string, overrides: Partial<UIKeyboardEvent> = {}): U
target: node,
currentTarget: node,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...overrides,
};
}
@@ -7,6 +7,7 @@ function wheelEvent(deltaY: number): UIWheelEvent {
return {
deltaY,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
}
@@ -99,6 +99,7 @@ describe('createTooltip', () => {
pointerType: 'mouse',
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
expect(onOpenChange).not.toHaveBeenCalled();
@@ -131,6 +132,7 @@ describe('createTooltip', () => {
pointerType: 'touch',
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
vi.advanceTimersByTime(600);
@@ -148,6 +150,7 @@ describe('createTooltip', () => {
pointerType: 'mouse',
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
vi.advanceTimersByTime(600);
@@ -164,11 +167,12 @@ describe('createTooltip', () => {
pointerType: 'touch' as const,
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
// Simulate tap: pointerdown → focusin (flag consumed and reset)
tooltip.triggerProps.onPointerDown(pointerEvent);
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).not.toHaveBeenCalled();
});
@@ -177,7 +181,7 @@ describe('createTooltip', () => {
const { tooltip, onOpenChange } = createTestTooltip();
// Simulate keyboard Tab: focusin without preceding pointerdown
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' });
});
@@ -191,15 +195,16 @@ describe('createTooltip', () => {
pointerType: 'touch' as const,
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
// Tap: pointerdown → focusin (suppressed, flag consumed)
tooltip.triggerProps.onPointerDown(pointerEvent);
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).not.toHaveBeenCalled();
// Later keyboard Tab: flag is clean, focus opens tooltip
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' });
});
});
@@ -209,7 +214,7 @@ describe('createTooltip', () => {
disabled: () => true,
});
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(onOpenChange).not.toHaveBeenCalled();
});
@@ -231,6 +236,7 @@ describe('createTooltip', () => {
pointerType: 'mouse',
buttons: 0,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
});
+9 -8
View File
@@ -3,17 +3,18 @@ export { namedNodeMapToObject, serializeAttributes } from './attributes';
export { isRTL } from './direction';
export { type OnEventOptions, onEvent, resolveEventTarget } from './event';
export { idleCallback } from './idle-callback';
export {
EDITABLE_SELECTOR,
INTERACTIVE_SELECTOR,
isEditableElement,
isEditableTarget,
isInteractiveActivation,
isInteractiveTarget,
} from './interactive';
export { listen } from './listen';
export { isMacOS } from './platform';
export { tryHidePopover, tryShowPopover } from './popover';
export {
isEditableElement,
isEditableTarget,
isHTMLAudioElement,
isHTMLMediaElement,
isHTMLVideoElement,
isInteractiveTarget,
} from './predicates';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export {
applyShadowStyles,
+47
View File
@@ -0,0 +1,47 @@
import { resolveEventTarget } from './event';
export const INTERACTIVE_SELECTOR = 'button,input,select,textarea,a[href],[role="slider"],[role="button"]';
const EDITABLE_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
export const EDITABLE_SELECTOR = [
'textarea',
'select',
'input:not([type])',
...EDITABLE_INPUT_TYPES.map((type) => `input[type="${type}"]`),
'[contenteditable]:not([contenteditable="false"])',
].join(',');
export function isEditableElement(el: Element): boolean {
return el.matches(EDITABLE_SELECTOR);
}
/** Whether the keyboard event target is an editable element (input, textarea, etc). */
export function isEditableTarget(event: KeyboardEvent): boolean {
const target = resolveEventTarget(event);
return target instanceof Element && isEditableElement(target);
}
/** Whether the event originated from an interactive control (button, slider, etc). */
export function isInteractiveTarget(event: Event): boolean {
const target = resolveEventTarget(event);
if (!(target instanceof Element)) return false;
return target.closest(INTERACTIVE_SELECTOR) !== null;
}
const ACTIVATION_KEYS = new Set([' ', 'Enter']);
/**
* Selector for elements that use Space/Enter as a native activation key.
* Narrower than `INTERACTIVE_SELECTOR` excludes editable elements like
* `input`, `textarea`, `select` where Space/Enter is text input, not activation.
*/
const ACTIVATABLE_SELECTOR = 'button,a[href],[role="slider"],[role="button"]';
/** Whether the event is an activation key on an activatable element (button, link, slider). */
export function isInteractiveActivation(event: KeyboardEvent): boolean {
if (!ACTIVATION_KEYS.has(event.key)) return false;
const target = resolveEventTarget(event);
return target instanceof Element && target.matches(ACTIVATABLE_SELECTOR);
}
-33
View File
@@ -1,5 +1,3 @@
import { resolveEventTarget } from './event';
export function isHTMLVideoElement(value: unknown): value is HTMLVideoElement {
return value instanceof HTMLVideoElement;
}
@@ -11,34 +9,3 @@ export function isHTMLAudioElement(value: unknown): value is HTMLAudioElement {
export function isHTMLMediaElement(value: unknown): value is HTMLMediaElement {
return value instanceof HTMLMediaElement;
}
const EDITABLE_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number']);
export function isEditableElement(el: Element): boolean {
if (el instanceof HTMLTextAreaElement) return true;
if (el instanceof HTMLSelectElement) return true;
if (el instanceof HTMLInputElement) {
return EDITABLE_INPUT_TYPES.has(el.type.toLowerCase());
}
if (!(el instanceof HTMLElement)) return false;
const editable = el.getAttribute('contenteditable');
return editable !== null && editable !== 'false';
}
/** Whether the keyboard event target is an editable element (input, textarea, etc). */
export function isEditableTarget(event: KeyboardEvent): boolean {
const target = resolveEventTarget(event);
return target instanceof Element && isEditableElement(target);
}
const INTERACTIVE_SELECTOR = 'button, input, select, textarea, [role="button"], [role="slider"]';
/** Whether the event originated from an interactive control (button, slider, etc). */
export function isInteractiveTarget(event: Event): boolean {
const target = resolveEventTarget(event);
if (!(target instanceof Element)) return false;
return target.closest(INTERACTIVE_SELECTOR) !== null;
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { isEditableTarget, isInteractiveActivation } from '../interactive';
function keydown(target: EventTarget, options?: KeyboardEventInit): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, ...options });
target.dispatchEvent(event);
return event;
}
describe('isEditableTarget', () => {
it('returns true for text input', () => {
const input = document.createElement('input');
input.type = 'text';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns true for textarea', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
const event = keydown(textarea);
expect(isEditableTarget(event)).toBe(true);
textarea.remove();
});
it('returns true for select', () => {
const select = document.createElement('select');
document.body.appendChild(select);
const event = keydown(select);
expect(isEditableTarget(event)).toBe(true);
select.remove();
});
it('returns true for contenteditable element', () => {
const div = document.createElement('div');
div.setAttribute('contenteditable', 'true');
document.body.appendChild(div);
const event = keydown(div);
expect(isEditableTarget(event)).toBe(true);
div.remove();
});
it('returns false for button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
const event = keydown(button);
expect(isEditableTarget(event)).toBe(false);
button.remove();
});
it('returns false for range input', () => {
const input = document.createElement('input');
input.type = 'range';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(false);
input.remove();
});
it('returns false for plain div', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const event = keydown(div);
expect(isEditableTarget(event)).toBe(false);
div.remove();
});
it('returns true for email input', () => {
const input = document.createElement('input');
input.type = 'email';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns true for search input', () => {
const input = document.createElement('input');
input.type = 'search';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns false for checkbox input', () => {
const input = document.createElement('input');
input.type = 'checkbox';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(false);
input.remove();
});
});
describe('isInteractiveActivation', () => {
it('returns true for Space on a button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
const event = keydown(button, { key: ' ' });
expect(isInteractiveActivation(event)).toBe(true);
button.remove();
});
it('returns true for Enter on a button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
const event = keydown(button, { key: 'Enter' });
expect(isInteractiveActivation(event)).toBe(true);
button.remove();
});
it('returns false for non-activation key on a button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
const event = keydown(button, { key: 'a' });
expect(isInteractiveActivation(event)).toBe(false);
button.remove();
});
it('returns false for Space on a non-interactive element', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const event = keydown(div, { key: ' ' });
expect(isInteractiveActivation(event)).toBe(false);
div.remove();
});
it('returns true for Space on element with role="slider"', () => {
const div = document.createElement('div');
div.setAttribute('role', 'slider');
document.body.appendChild(div);
const event = keydown(div, { key: ' ' });
expect(isInteractiveActivation(event)).toBe(true);
div.remove();
});
});
+1 -115
View File
@@ -1,12 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isEditableTarget, isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates';
function keydown(target: EventTarget, options?: KeyboardEventInit): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, ...options });
target.dispatchEvent(event);
return event;
}
import { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates';
describe('DOM predicates', () => {
describe('isHTMLVideoElement', () => {
@@ -81,111 +75,3 @@ describe('DOM predicates', () => {
});
});
});
describe('isEditableTarget', () => {
it('returns true for text input', () => {
const input = document.createElement('input');
input.type = 'text';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns true for textarea', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
const event = keydown(textarea);
expect(isEditableTarget(event)).toBe(true);
textarea.remove();
});
it('returns true for select', () => {
const select = document.createElement('select');
document.body.appendChild(select);
const event = keydown(select);
expect(isEditableTarget(event)).toBe(true);
select.remove();
});
it('returns true for contenteditable element', () => {
const div = document.createElement('div');
div.setAttribute('contenteditable', 'true');
document.body.appendChild(div);
const event = keydown(div);
expect(isEditableTarget(event)).toBe(true);
div.remove();
});
it('returns false for button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
const event = keydown(button);
expect(isEditableTarget(event)).toBe(false);
button.remove();
});
it('returns false for range input', () => {
const input = document.createElement('input');
input.type = 'range';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(false);
input.remove();
});
it('returns false for plain div', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const event = keydown(div);
expect(isEditableTarget(event)).toBe(false);
div.remove();
});
it('returns true for email input', () => {
const input = document.createElement('input');
input.type = 'email';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns true for search input', () => {
const input = document.createElement('input');
input.type = 'search';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(true);
input.remove();
});
it('returns false for checkbox input', () => {
const input = document.createElement('input');
input.type = 'checkbox';
document.body.appendChild(input);
const event = keydown(input);
expect(isEditableTarget(event)).toBe(false);
input.remove();
});
});