feat(packages): add hotkey system with coordinator, actions, and ARIA support (#1238)

This commit is contained in:
rahim
2026-04-07 00:13:05 -07:00
committed by GitHub
parent 932949fdf1
commit 627ea204fc
14 changed files with 1476 additions and 3 deletions
+6
View File
@@ -1,3 +1,9 @@
/** Resolve the deepest event target, preferring composedPath for shadow DOM. */
export function resolveEventTarget(event: Event): EventTarget | null {
const path = event.composedPath();
return path.length > 0 ? path[0]! : event.target;
}
export interface OnEventOptions extends AddEventListenerOptions {
/**
* An AbortSignal to cancel waiting for the event.
+9 -2
View File
@@ -1,11 +1,18 @@
export { animationFrame } from './animation-frame';
export { namedNodeMapToObject } from './attributes';
export { isRTL } from './direction';
export { type OnEventOptions, onEvent } from './event';
export { type OnEventOptions, onEvent, resolveEventTarget } from './event';
export { idleCallback } from './idle-callback';
export { listen } from './listen';
export { isMacOS } from './platform';
export { tryHidePopover, tryShowPopover } from './popover';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
export {
isEditableElement,
isEditableTarget,
isHTMLAudioElement,
isHTMLMediaElement,
isHTMLVideoElement,
} from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export {
applyShadowStyles,
+3
View File
@@ -0,0 +1,3 @@
export function isMacOS(): boolean {
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent);
}
+24
View File
@@ -1,3 +1,5 @@
import { resolveEventTarget } from './event';
export function isHTMLVideoElement(value: unknown): value is HTMLVideoElement {
return value instanceof HTMLVideoElement;
}
@@ -9,3 +11,25 @@ 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);
}
+115 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates';
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;
}
describe('DOM predicates', () => {
describe('isHTMLVideoElement', () => {
@@ -75,3 +81,111 @@ 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();
});
});