From bd3de66be81c15b4bdc83f34600ce9d828545e3a Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Fri, 16 Oct 2026 17:35:57 +0300 Subject: [PATCH 1/2] feat(core): add internal key binding service --- .../core/src/core/key-bindings.ts | 2 + .../key-bindings/key-bindings.service.spec.ts | 627 ++++++++++++++++++ .../core/key-bindings/key-bindings.service.ts | 483 ++++++++++++++ .../core/src/core/key-bindings/keys.ts | 56 ++ .../igniteui-angular/core/src/public_api.ts | 1 + 5 files changed, 1169 insertions(+) create mode 100644 projects/igniteui-angular/core/src/core/key-bindings.ts create mode 100644 projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts create mode 100644 projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.ts create mode 100644 projects/igniteui-angular/core/src/core/key-bindings/keys.ts diff --git a/projects/igniteui-angular/core/src/core/key-bindings.ts b/projects/igniteui-angular/core/src/core/key-bindings.ts new file mode 100644 index 00000000000..ca1cb526ed2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings.ts @@ -0,0 +1,2 @@ +export * from './key-bindings/keys'; +export * from './key-bindings/key-bindings.service'; diff --git a/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts new file mode 100644 index 00000000000..ea8d0caa3d7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts @@ -0,0 +1,627 @@ +import { + ChangeDetectionStrategy, + Component, + NgZone, + PLATFORM_ID, + inject, + provideZoneChangeDetection, + provideZonelessChangeDetection +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { IgxKeyBindingsService } from './key-bindings.service'; +import { + altKey, + arrowDown, + arrowUp, + ctrlKey, + enterKey, + escapeKey, + isKey, + shiftKey, + spaceBar +} from './keys'; + +describe('IgxKeyBindingsService', () => { + describe('Provided by a component', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + let keyBindings: IgxKeyBindingsService; + let handler: jasmine.Spy<(event: KeyboardEvent) => void>; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [KeyBindingsHostComponent] }); + fixture = TestBed.createComponent(KeyBindingsHostComponent); + fixture.detectChanges(); + host = fixture.nativeElement; + keyBindings = fixture.componentInstance.keyBindings; + handler = jasmine.createSpy('handler'); + }); + + describe('Triggers', () => { + it('should run a keydown binding on keydown only', () => { + keyBindings.set('a', handler); + + dispatch(host, 'keyup', 'a'); + expect(handler).not.toHaveBeenCalled(); + + const event = dispatch(host, 'keydown', 'a'); + expect(handler).toHaveBeenCalledOnceWith(event); + }); + + it('should run a keyup binding on keyup only', () => { + keyBindings.set('s', handler, { triggers: ['keyup'] }); + + dispatch(host, 'keydown', 's'); + expect(handler).not.toHaveBeenCalled(); + + const event = dispatch(host, 'keyup', 's'); + expect(handler).toHaveBeenCalledOnceWith(event); + }); + + it('should run a binding with both triggers on keydown and keyup', () => { + keyBindings.set('d', handler, { triggers: ['keydown', 'keyup'] }); + + press(host, 'd'); + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.calls.argsFor(0)[0].type).toBe('keydown'); + expect(handler.calls.argsFor(1)[0].type).toBe('keyup'); + }); + + it('should ignore keys without a binding', () => { + keyBindings.set('a', handler); + + const event = dispatch(host, 'keydown', 'b'); + expect(handler).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBeFalse(); + }); + + it('should ignore events without a key', () => { + keyBindings.set('a', handler); + + // Browser autofill dispatches a plain `Event` named `keydown`. + expect(() => host.dispatchEvent(new Event('keydown', { bubbles: true }))).not.toThrow(); + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('Skip', () => { + it('should skip keys from inner input, textarea and select elements by default', () => { + keyBindings.set('k', handler); + + for (const selector of ['input', 'textarea', 'select']) { + dispatch(host.querySelector(selector)!, 'keydown', 'k'); + } + expect(handler).not.toHaveBeenCalled(); + + dispatch(host.querySelector('.inner')!, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should skip keys from elements that match custom selectors', () => { + keyBindings.configure({ skip: ['.inner'] }).set('k', handler); + + dispatch(host.querySelector('.inner')!, 'keydown', 'k'); + expect(handler).not.toHaveBeenCalled(); + + dispatch(host.querySelector('input')!, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should not skip any element when the selector list is empty', () => { + keyBindings.configure({ skip: [] }).set('k', handler); + + dispatch(host.querySelector('input')!, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should honor a skip callback and pass it the target and the event', () => { + let skip = true; + const skipCallback = jasmine.createSpy('skip').and.callFake(() => skip); + keyBindings.configure({ skip: skipCallback }).set('k', handler); + + const inner = host.querySelector('.inner')!; + const event = dispatch(inner, 'keydown', 'k'); + expect(skipCallback).toHaveBeenCalledOnceWith(inner, event); + expect(handler).not.toHaveBeenCalled(); + + skip = false; + dispatch(inner, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should not call the skip callback for keys without a binding', () => { + const skipCallback = jasmine.createSpy('skip').and.returnValue(false); + keyBindings.configure({ skip: skipCallback }).set('k', handler); + + dispatch(host, 'keydown', 'q'); + expect(skipCallback).not.toHaveBeenCalled(); + }); + + it('should replace the default selectors with a skip callback', () => { + keyBindings.configure({ skip: () => false }).set('k', handler); + + dispatch(host.querySelector('input')!, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should release a key on a skipped keyup', () => { + keyBindings.set(['x', 'z'], handler); + + dispatch(host, 'keydown', 'x'); + // Focus moved into the input before the key was released. + dispatch(host.querySelector('input')!, 'keyup', 'x'); + dispatch(host, 'keydown', 'z'); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('Event modifiers', () => { + it('should prevent the default action by default', () => { + keyBindings.set('a', handler); + + expect(dispatch(host, 'keydown', 'a').defaultPrevented).toBeTrue(); + }); + + it('should not prevent the default action when preventDefault is false', () => { + keyBindings.set('a', handler, { preventDefault: false }); + + expect(dispatch(host, 'keydown', 'a').defaultPrevented).toBeFalse(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should prevent the default action on keyup triggers too', () => { + keyBindings.set('d', handler, { triggers: ['keydown', 'keyup'] }); + + expect(dispatch(host, 'keydown', 'd').defaultPrevented).toBeTrue(); + expect(dispatch(host, 'keyup', 'd').defaultPrevented).toBeTrue(); + }); + + it('should stop propagation when stopPropagation is true', () => { + const parentHandler = jasmine.createSpy('parentHandler'); + host.parentElement!.addEventListener('keydown', parentHandler); + keyBindings.set('d', handler, { stopPropagation: true }); + + dispatch(host, 'keydown', 'd'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); + host.parentElement!.removeEventListener('keydown', parentHandler); + }); + + it('should let the event propagate by default', () => { + const parentHandler = jasmine.createSpy('parentHandler'); + host.parentElement!.addEventListener('keydown', parentHandler); + keyBindings.set('d', handler); + + dispatch(host, 'keydown', 'd'); + + expect(parentHandler).toHaveBeenCalledTimes(1); + host.parentElement!.removeEventListener('keydown', parentHandler); + }); + }); + + describe('Binding defaults', () => { + it('should apply the binding defaults, with the binding options on top', () => { + keyBindings + .configure({ bindingDefaults: { preventDefault: false, repeat: true } }) + .set('a', handler) + .set('b', handler, { repeat: false }); + + expect(dispatch(host, 'keydown', 'a', { repeat: true }).defaultPrevented).toBeFalse(); + expect(handler).toHaveBeenCalledTimes(1); + + dispatch(host, 'keyup', 'a'); + dispatch(host, 'keydown', 'b', { repeat: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should apply the binding defaults to bindings set before configure', () => { + keyBindings.set('a', handler); + keyBindings.configure({ bindingDefaults: { preventDefault: false } }); + + expect(dispatch(host, 'keydown', 'a').defaultPrevented).toBeFalse(); + }); + }); + + describe('Activation keys', () => { + it('should run the activate handler for Enter and Space', () => { + keyBindings.setActivateHandler(handler); + + press(host, enterKey); + press(host, spaceBar); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.calls.argsFor(0)[0].key).toBe(enterKey); + expect(handler.calls.argsFor(1)[0].key).toBe(spaceBar); + }); + }); + + describe('Combinations', () => { + it('should run a binding only for its full modifier combination', () => { + keyBindings + .set([shiftKey, 'c'], handler) + .set([shiftKey, altKey, arrowUp], handler); + + dispatch(host, 'keydown', shiftKey, { shiftKey: true }); + dispatch(host, 'keyup', shiftKey); + press(host, 'c'); + expect(handler).not.toHaveBeenCalled(); + + press(host, 'C', { shiftKey: true }); + expect(handler).toHaveBeenCalledTimes(1); + + press(host, arrowUp, { shiftKey: true, altKey: true }); + expect(handler).toHaveBeenCalledTimes(2); + + press(host, arrowUp, { shiftKey: true }); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('should not depend on the order or the case of the key names', () => { + keyBindings.set(['S', 'control'], handler); + + press(host, 's', { ctrlKey: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should match a symbol produced with Shift only together with Shift', () => { + const bangHandler = jasmine.createSpy('bangHandler'); + keyBindings.set('!', bangHandler).set([shiftKey, '!'], handler); + + // Shift+1 on a US layout: `key` is '!' and `shiftKey` is set. + press(host, '!', { shiftKey: true, code: 'Digit1' }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(bangHandler).not.toHaveBeenCalled(); + }); + }); + + describe('Chords without modifiers', () => { + beforeEach(() => keyBindings.set(['x', 'z'], handler)); + + it('should not run when only one of the keys is down', () => { + press(host, 'x'); + dispatch(host, 'keydown', 'z'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should run when all keys are down together', () => { + dispatch(host, 'keydown', 'x'); + dispatch(host, 'keydown', 'z'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should release the pressed keys on window blur', () => { + dispatch(host, 'keydown', 'x'); + window.dispatchEvent(new FocusEvent('blur')); + dispatch(host, 'keydown', 'z'); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('Repeat', () => { + it('should ignore a repeated keydown by default', () => { + keyBindings.set('a', handler); + + dispatch(host, 'keydown', 'a', { repeat: true }); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should run for a repeated keydown when repeat is true', () => { + keyBindings.set('b', handler, { repeat: true }); + + dispatch(host, 'keydown', 'b'); + dispatch(host, 'keydown', 'b', { repeat: true }); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('should run the right binding for overlapping key presses', () => { + const downHandler = jasmine.createSpy('downHandler'); + const upHandler = jasmine.createSpy('upHandler'); + keyBindings + .set(arrowDown, downHandler, { repeat: true }) + .set(arrowUp, upHandler, { repeat: true }); + + dispatch(host, 'keydown', arrowDown); + dispatch(host, 'keydown', arrowDown, { repeat: true }); + // ArrowDown is still down: it has no keyup yet. + dispatch(host, 'keydown', arrowUp); + + expect(upHandler).toHaveBeenCalledTimes(1); + + dispatch(host, 'keyup', arrowDown); + dispatch(host, 'keyup', arrowUp); + dispatch(host, 'keydown', arrowDown); + + expect(downHandler).toHaveBeenCalledTimes(3); + }); + }); + + describe('Dev-mode errors', () => { + it('should throw for a combination bound twice and keep the first binding', () => { + const second = jasmine.createSpy('second'); + keyBindings.set([shiftKey, 'a'], handler); + + expect(() => keyBindings.set(['A', shiftKey], second)).toThrowError(/shift\+a/); + + press(host, 'A', { shiftKey: true }); + expect(handler).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it('should throw for an activate handler on an already bound key', () => { + keyBindings.set(enterKey, handler); + + expect(() => keyBindings.setActivateHandler(handler)).toThrowError(/enter/); + }); + + it('should throw when configure is called a second time', () => { + keyBindings.configure({ skip: [] }); + + expect(() => keyBindings.configure({ skip: () => true })).toThrowError(/configure/); + + keyBindings.set('k', handler); + dispatch(host.querySelector('input')!, 'keydown', 'k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('observeElement', () => { + let external: HTMLElement; + + beforeEach(() => { + external = document.createElement('div'); + document.body.appendChild(external); + keyBindings.set(escapeKey, handler); + }); + + afterEach(() => external.remove()); + + it('should handle the keys of an element outside the host', () => { + keyBindings.observeElement(external); + + const event = dispatch(external, 'keydown', escapeKey); + expect(handler).toHaveBeenCalledOnceWith(event); + }); + + it('should keep handling the keys of the host while observing', () => { + keyBindings.observeElement(external); + + dispatch(host, 'keydown', escapeKey); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should handle the keys of several observed elements', () => { + const second = document.createElement('div'); + document.body.appendChild(second); + keyBindings.observeElement(external); + keyBindings.observeElement(second); + + dispatch(external, 'keydown', escapeKey); + dispatch(second, 'keydown', escapeKey); + + expect(handler).toHaveBeenCalledTimes(2); + second.remove(); + }); + + it('should stop handling only the unsubscribed element', () => { + const second = document.createElement('div'); + document.body.appendChild(second); + const { unsubscribe } = keyBindings.observeElement(external); + keyBindings.observeElement(second); + + unsubscribe(); + dispatch(external, 'keydown', escapeKey); + expect(handler).not.toHaveBeenCalled(); + + dispatch(second, 'keydown', escapeKey); + expect(handler).toHaveBeenCalledTimes(1); + second.remove(); + }); + + it('should run once for an observed element inside the host and keep bubbling', () => { + const parentHandler = jasmine.createSpy('parentHandler'); + host.parentElement!.addEventListener('keydown', parentHandler); + const inner = host.querySelector('.inner')!; + keyBindings.observeElement(inner); + + dispatch(inner, 'keydown', escapeKey); + + expect(handler).toHaveBeenCalledTimes(1); + expect(parentHandler).toHaveBeenCalledTimes(1); + host.parentElement!.removeEventListener('keydown', parentHandler); + }); + + it('should match the skip selectors up to the observed element', () => { + const input = document.createElement('input'); + external.appendChild(input); + keyBindings.observeElement(external); + + dispatch(input, 'keydown', escapeKey); + expect(handler).not.toHaveBeenCalled(); + + dispatch(external, 'keydown', escapeKey); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should handle the keys of an observed input element itself', () => { + const input = document.createElement('input'); + external.appendChild(input); + keyBindings.observeElement(input); + + dispatch(input, 'keydown', escapeKey); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should stop observing when the host is destroyed', () => { + keyBindings.observeElement(external); + + fixture.destroy(); + dispatch(external, 'keydown', escapeKey); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + it('should remove its listeners when destroyed', () => { + keyBindings.set('a', handler); + + fixture.destroy(); + dispatch(host, 'keydown', 'a'); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + it('should throw when it is not provided by an element', () => { + TestBed.configureTestingModule({ providers: [IgxKeyBindingsService] }); + + expect(() => TestBed.inject(IgxKeyBindingsService)).toThrowError(/providers/); + }); + + describe('Change detection', () => { + it('should run a bound handler inside the Angular zone, and nothing else', () => { + TestBed.configureTestingModule({ + imports: [KeyBindingsCounterComponent], + providers: [provideZoneChangeDetection()] + }); + const fixture = TestBed.createComponent(KeyBindingsCounterComponent); + fixture.detectChanges(); + const zone = TestBed.inject(NgZone); + const host: HTMLElement = fixture.nativeElement; + let zoneEntries = 0; + const subscription = zone.onUnstable.subscribe(() => zoneEntries++); + + press(host, 'q'); + press(host, 'q', { ctrlKey: true }); + expect(zoneEntries).toBe(0); + + dispatch(host, 'keydown', enterKey); + expect(zoneEntries).toBe(1); + expect(fixture.componentInstance.ranInZone).toBeTrue(); + subscription.unsubscribe(); + }); + + it('should refresh an OnPush host after a bound handler changes a plain field', () => { + TestBed.configureTestingModule({ + imports: [KeyBindingsCounterComponent], + providers: [provideZoneChangeDetection()] + }); + const fixture = TestBed.createComponent(KeyBindingsCounterComponent); + fixture.detectChanges(); + const host: HTMLElement = fixture.nativeElement; + + dispatch(host, 'keydown', enterKey); + fixture.detectChanges(); + + expect(host.querySelector('.count')!.textContent).toBe('1'); + }); + + it('should refresh the host after a bound handler without Zone.js', async () => { + TestBed.configureTestingModule({ + imports: [KeyBindingsCounterComponent], + providers: [provideZonelessChangeDetection()] + }); + const fixture = TestBed.createComponent(KeyBindingsCounterComponent); + await fixture.whenStable(); + const host: HTMLElement = fixture.nativeElement; + + dispatch(host, 'keydown', enterKey); + await fixture.whenStable(); + + expect(host.querySelector('.count')!.textContent).toBe('1'); + }); + }); + + describe('Server platform', () => { + it('should not add listeners outside the browser', () => { + const addListener = spyOn(window, 'addEventListener').and.callThrough(); + TestBed.configureTestingModule({ + imports: [KeyBindingsHostComponent], + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }); + const fixture = TestBed.createComponent(KeyBindingsHostComponent); + const keyBindings = fixture.componentInstance.keyBindings; + const handler = jasmine.createSpy('handler'); + const external = document.createElement('div'); + + keyBindings.set('a', handler); + const { unsubscribe } = keyBindings.observeElement(external); + dispatch(fixture.nativeElement, 'keydown', 'a'); + dispatch(external, 'keydown', 'a'); + + expect(handler).not.toHaveBeenCalled(); + expect(addListener).not.toHaveBeenCalledWith('blur', jasmine.anything(), jasmine.anything()); + expect(() => unsubscribe()).not.toThrow(); + }); + }); +}); + +describe('isKey', () => { + it('should compare the key of the event without regard to case', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + + expect(isKey(event, arrowUp)).toBeTrue(); + expect(isKey(event, 'arrowup')).toBeTrue(); + expect(isKey(event, arrowDown)).toBeFalse(); + expect(isKey(new KeyboardEvent('keydown', { key: 'Control' }), ctrlKey)).toBeTrue(); + }); +}); + +@Component({ + selector: 'test-key-bindings-host', + template: ` + + + +
+ `, + providers: [IgxKeyBindingsService] +}) +class KeyBindingsHostComponent { + public readonly keyBindings = inject(IgxKeyBindingsService); +} + +@Component({ + selector: 'test-key-bindings-counter', + template: `{{ count }}`, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [IgxKeyBindingsService] +}) +class KeyBindingsCounterComponent { + public count = 0; + public ranInZone = false; + + constructor() { + inject(IgxKeyBindingsService).set(enterKey, () => { + this.ranInZone = NgZone.isInAngularZone(); + this.count++; + }); + } +} + +function dispatch( + target: EventTarget, + type: 'keydown' | 'keyup', + key: string, + init: KeyboardEventInit = {} +): KeyboardEvent { + const event = new KeyboardEvent(type, { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init + }); + target.dispatchEvent(event); + return event; +} + +function press(target: EventTarget, key: string, init: KeyboardEventInit = {}): void { + dispatch(target, 'keydown', key, init); + dispatch(target, 'keyup', key, init); +} diff --git a/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.ts b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.ts new file mode 100644 index 00000000000..72fac0a508f --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.ts @@ -0,0 +1,483 @@ +import { + ChangeDetectorRef, + DestroyRef, + DOCUMENT, + ElementRef, + Injectable, + NgZone, + PLATFORM_ID, + inject, + isDevMode +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { enterKey, spaceBar } from './keys'; + +//#region Modifiers and combination keys + +/** + * Each modifier and the `KeyboardEvent` property it reads. A combination key + * keeps this alphabetical order. + */ +const MODIFIER_ENTRIES = [ + ['alt', 'altKey'], + ['control', 'ctrlKey'], + ['meta', 'metaKey'], + ['shift', 'shiftKey'] +] as const satisfies ReadonlyArray; + +const MODIFIER_NAMES: readonly string[] = MODIFIER_ENTRIES.map(([name]) => name); +const MODIFIERS = new Set(MODIFIER_NAMES); + +/** Splits the keys into modifiers and normal keys, in lower case. */ +function parseKeys(input: string | string[]): { keys: string[]; modifiers: string[] } { + const keys: string[] = []; + const modifiers: string[] = []; + + for (const key of Array.isArray(input) ? input : [input]) { + const name = key.toLowerCase(); + (MODIFIERS.has(name) ? modifiers : keys).push(name); + } + + return { keys, modifiers }; +} + +/** Sorts `modifiers` alphabetically. */ +function sortModifiers(modifiers: string[]): string[] { + return MODIFIER_NAMES.filter((name) => modifiers.includes(name)); +} + +/** Returns the modifiers active for `event`, already sorted. */ +function getActiveModifiers(event: KeyboardEvent): string[] { + return MODIFIER_ENTRIES.filter(([, property]) => event[property]).map(([name]) => name); +} + +function hasModifiers(event: KeyboardEvent): boolean { + return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; +} + +/** + * Joins keys and modifiers into a `+` separated combination key, sorted + * alphabetically. `modifiers` must already be sorted. + */ +function createCombinationKey(keys: string[], modifiers: string[]): string { + return modifiers.concat(keys.length > 1 ? [...keys].sort() : keys).join('+'); +} + +//#endregion + +//#region Types + +/** + * The handler of a key binding. + * + * @hidden @internal + */ +export type IgxKeyBindingHandler = (event: KeyboardEvent) => void; + +/** + * Whether the service must ignore the event. `target` is the event target. + * + * @hidden @internal + */ +export type IgxKeyBindingSkipCallback = (target: Element, event: KeyboardEvent) => boolean; + +/** + * The event type that runs the bound handler. + * + * @hidden @internal + */ +export type IgxKeyBindingTrigger = 'keydown' | 'keyup'; + +/** + * The options of a single key binding. + * + * @hidden @internal + */ +export interface IgxKeyBindingOptions { + /** The event types that run the handler. Defaults to `['keydown']`. */ + triggers?: IgxKeyBindingTrigger[]; + /** Whether the handler runs for a repeated keydown. Defaults to `false`. */ + repeat?: boolean; + /** Whether to call `preventDefault` before the handler runs. Defaults to `true`. */ + preventDefault?: boolean; + /** Whether to call `stopPropagation` before the handler runs. Defaults to `false`. */ + stopPropagation?: boolean; +} + +/** + * The configuration of an {@link IgxKeyBindingsService}. + * + * @hidden @internal + */ +export interface IgxKeyBindingsConfig { + /** + * The key presses that the service ignores. CSS selectors match against + * the composed path of the event, from the target up to the element that + * caught it; a {@link IgxKeyBindingSkipCallback} decides per event instead + * of the selectors. Defaults to `['input', 'textarea', 'select']`. + */ + skip?: string[] | IgxKeyBindingSkipCallback; + /** The default options of every binding. The options of a binding take precedence. */ + bindingDefaults?: IgxKeyBindingOptions; +} + +/** + * Stops the keyboard handling of an element observed with + * {@link IgxKeyBindingsService.observeElement}. + * + * @hidden @internal + */ +export interface IgxKeyBindingObserver { + unsubscribe(): void; +} + +/** A registered binding. The key of the binding map holds the combination. */ +interface KeyBinding { + handler: IgxKeyBindingHandler; + options?: IgxKeyBindingOptions; +} + +//#endregion + +const DEFAULT_SKIP = ['input', 'textarea', 'select']; +const DEFAULT_BINDING_OPTIONS: IgxKeyBindingOptions = { preventDefault: true }; +const DEFAULT_TRIGGERS: IgxKeyBindingTrigger[] = ['keydown']; + +function isKeyup(event: Event): boolean { + return event.type === 'keyup'; +} + +function isElement(node: EventTarget): node is Element { + return (node as Node).nodeType === Node.ELEMENT_NODE; +} + +/** The host element, which only exists when an element provides the service. */ +function injectHost(): HTMLElement { + const host = inject>(ElementRef, { optional: true }); + if (!host) { + throw new Error( + 'IgxKeyBindingsService has no host element. ' + + 'List it in the providers of the component or directive that uses it.' + ); + } + return host.nativeElement; +} + +/** + * Manages the key bindings of the element of the component or directive that + * provides it. + * + * @remarks + * List it in the `providers` of the component or directive and `inject()` it. + * The owners on one element share one instance, so only one of them may call + * {@link configure}. A child element that injects it without providing it gets + * the instance of its closest ancestor. Use {@link observeElement} for + * elements outside the view, such as overlay content. + * + * @hidden @internal + */ +@Injectable() +export class IgxKeyBindingsService { + private readonly _host = injectHost(); + private readonly _zone = inject(NgZone); + private readonly _cdr = inject(ChangeDetectorRef); + private readonly _document = inject(DOCUMENT); + private readonly _isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly _destroyRef = inject(DestroyRef); + + private readonly _bindings = new Map(); + private readonly _allowedKeys = new Set(); + private readonly _pressedKeys = new Set(); + /** Events already handled, for an observed element inside the host. */ + private readonly _handledEvents = new WeakSet(); + /** One controller for the host and window listeners, one per observed element. */ + private readonly _listeners = new Set(); + + private _configured = false; + private _bindingDefaults = DEFAULT_BINDING_OPTIONS; + private _skipSelector?: string = DEFAULT_SKIP.join(','); + private _skipCallback?: IgxKeyBindingSkipCallback; + + constructor() { + this._destroyRef.onDestroy(() => { + for (const controller of this._listeners) { + controller.abort(); + } + this._listeners.clear(); + }); + + if (!this._isBrowser) { + return; + } + + const { signal } = this._createListeners(); + this._zone.runOutsideAngular(() => { + this._listen(this._host, signal); + // No keyup arrives when the user leaves the window with a key down. + this._document.defaultView?.addEventListener('blur', this._onWindowBlur, { signal }); + }); + } + + //#region Public API + + /** + * Configures the service. Call it once, before or after the bindings. + * + * @remarks + * The owners on one element share the instance and must agree on one call; + * a second call throws in dev mode and is ignored otherwise. + * + * @param config - The skip condition and the default binding options. + * @returns The service, for chaining. + * + * @example + * ```typescript + * this._keyBindings + * .configure({ skip: () => this.disabled, bindingDefaults: { repeat: true } }) + * .set(arrowDown, () => this.next()); + * ``` + */ + public configure(config: IgxKeyBindingsConfig): this { + if (this._configured) { + if (isDevMode()) { + throw new Error( + 'IgxKeyBindingsService: configure() was already called for this element. ' + + 'The owners that share the instance must agree on one configuration.' + ); + } + return this; + } + + this._configured = true; + this._bindingDefaults = { ...DEFAULT_BINDING_OPTIONS, ...config.bindingDefaults }; + + const skip = config.skip ?? DEFAULT_SKIP; + if (typeof skip === 'function') { + this._skipCallback = skip; + this._skipSelector = undefined; + } else { + this._skipSelector = skip.length ? skip.join(',') : undefined; + } + + return this; + } + + /** + * Registers a binding for a key or a combination, such as + * `[shiftKey, arrowUp]`. The order and the case of the names do not + * matter. + * + * @remarks + * A combination can be bound once. A second binding throws in dev mode and + * is ignored otherwise, so the first binding stays. + * + * @param keys - A `KeyboardEvent.key` value, or the keys of a combination. + * @param handler - Runs inside the Angular zone when the binding matches. + * @param options - The options of the binding, over the binding defaults. + * @returns The service, for chaining. + * + * @example + * ```typescript + * this._keyBindings + * .set(escapeKey, () => this.close()) + * .set([altKey, arrowUp], () => this.close(), { repeat: false }); + * ``` + */ + public set(keys: string | string[], handler: IgxKeyBindingHandler, options?: IgxKeyBindingOptions): this { + const parsed = parseKeys(keys); + const combination = createCombinationKey(parsed.keys, sortModifiers(parsed.modifiers)); + + if (this._bindings.has(combination)) { + if (isDevMode()) { + throw new Error(`IgxKeyBindingsService: the key combination "${combination}" is already bound for this element.`); + } + return this; + } + + for (const key of [...parsed.keys, ...parsed.modifiers]) { + this._allowedKeys.add(key); + } + this._bindings.set(combination, { handler, options }); + + return this; + } + + /** + * Registers `handler` for both the Enter key and the Space bar. + * + * @param handler - Runs inside the Angular zone for Enter or Space. + * @param options - The options of both bindings, over the binding defaults. + * @returns The service, for chaining. + * + * @example + * ```typescript + * this._keyBindings.setActivateHandler((event) => this.toggle(event)); + * ``` + */ + public setActivateHandler(handler: IgxKeyBindingHandler, options?: IgxKeyBindingOptions): this { + return this.set(enterKey, handler, options).set(spaceBar, handler, options); + } + + /** + * Handles the keyboard events of another element, such as overlay + * content, with the configuration and the bindings of this service. The + * service stops when it is destroyed or when `unsubscribe` is called. + * + * @param element - The element to observe. + * @returns An object whose `unsubscribe` stops observing `element`. + * + * @example + * ```typescript + * const { unsubscribe } = this._keyBindings.observeElement(overlayContent); + * // later, when the overlay closes + * unsubscribe(); + * ``` + */ + public observeElement(element: Element): IgxKeyBindingObserver { + if (!this._isBrowser) { + return { unsubscribe: () => {} }; + } + + const controller = this._createListeners(); + this._zone.runOutsideAngular(() => this._listen(element, controller.signal)); + + return { + unsubscribe: () => { + controller.abort(); + this._listeners.delete(controller); + } + }; + } + + //#endregion + + //#region Event handling + + private _createListeners(): AbortController { + const controller = new AbortController(); + this._listeners.add(controller); + return controller; + } + + private _listen(element: EventTarget, signal: AbortSignal): void { + element.addEventListener('keydown', this._onKeyEvent, { signal }); + element.addEventListener('keyup', this._onKeyEvent, { signal }); + } + + private readonly _onWindowBlur = (): void => { + this._pressedKeys.clear(); + }; + + private readonly _onKeyEvent = (event: Event): void => { + // An observed element inside the host sees the event before the host. + if (this._handledEvents.has(event)) { + return; + } + this._handledEvents.add(event); + + // Browser autofill dispatches a plain `Event` named `keydown`. + const keyboardEvent = event as KeyboardEvent; + if (typeof keyboardEvent.key === 'string') { + this._handleKeyEvent(keyboardEvent, event.currentTarget as Element); + } + }; + + private _handleKeyEvent(event: KeyboardEvent, scope: Element): void { + const key = event.key.toLowerCase(); + const isModifier = MODIFIERS.has(key); + + if (this._shouldSkip(event, key, scope)) { + // A skipped keyup still releases the key. + if (!isModifier && isKeyup(event)) { + this._pressedKeys.delete(key); + } + return; + } + + if (!isModifier) { + this._pressedKeys.add(key); + } + + const binding = this._findBinding(event, key, isModifier); + if (binding) { + this._runBinding(binding, event); + } + + if (!isModifier && isKeyup(event)) { + this._pressedKeys.delete(key); + } + } + + private _findBinding(event: KeyboardEvent, key: string, isModifier: boolean): KeyBinding | undefined { + if (!isModifier && !hasModifiers(event) && this._pressedKeys.size === 1) { + // Fast path: a single key without modifiers is its own combination. + return this._bindings.get(key); + } + + const modifiers = getActiveModifiers(event); + const binding = this._bindings.get(createCombinationKey(Array.from(this._pressedKeys), modifiers)); + + // Overlapping presses leave several keys down. Fall back to the current + // key, so its binding still runs. + if (!binding && this._pressedKeys.size > 1) { + return this._bindings.get(createCombinationKey([key], modifiers)); + } + + return binding; + } + + private _runBinding(binding: KeyBinding, event: KeyboardEvent): void { + const options = { ...this._bindingDefaults, ...binding.options }; + const triggers = options.triggers ?? DEFAULT_TRIGGERS; + + if (!triggers.includes(event.type as IgxKeyBindingTrigger)) { + return; + } + if (!isKeyup(event) && event.repeat && !options.repeat) { + return; + } + + if (options.preventDefault) { + event.preventDefault(); + } + if (options.stopPropagation) { + event.stopPropagation(); + } + + // Same as a template listener: enter the zone and mark the view, so + // OnPush and zoneless hosts refresh. + this._zone.run(() => { + this._cdr.markForCheck(); + binding.handler(event); + }); + } + + /** + * Whether to ignore the event: the key has no binding, a node between the + * target and `scope` matches the skip selectors, or the skip callback + * agrees. + */ + private _shouldSkip(event: KeyboardEvent, key: string, scope: Element): boolean { + if (!this._allowedKeys.has(key)) { + return true; + } + + const selector = this._skipSelector; + if (selector) { + // The path runs target first; `scope` itself is never matched, so + // the service works on an input element. + for (const node of event.composedPath()) { + if (node === scope) { + break; + } + if (isElement(node) && node.matches(selector)) { + return true; + } + } + } + + return this._skipCallback?.(event.target as Element, event) ?? false; + } + + //#endregion +} diff --git a/projects/igniteui-angular/core/src/core/key-bindings/keys.ts b/projects/igniteui-angular/core/src/core/key-bindings/keys.ts new file mode 100644 index 00000000000..d7804964256 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings/keys.ts @@ -0,0 +1,56 @@ +/** + * The key names, and the case-insensitive key check. + * + * @remarks + * The module has no imports, so a consumer of a key name depends on nothing + * else. The names are `KeyboardEvent.key` values. + */ + +/** @hidden @internal */ +export const arrowLeft = 'ArrowLeft' as const; +/** @hidden @internal */ +export const arrowRight = 'ArrowRight' as const; +/** @hidden @internal */ +export const arrowUp = 'ArrowUp' as const; +/** @hidden @internal */ +export const arrowDown = 'ArrowDown' as const; +/** @hidden @internal */ +export const enterKey = 'Enter' as const; +/** @hidden @internal */ +export const spaceBar = ' ' as const; +/** @hidden @internal */ +export const escapeKey = 'Escape' as const; +/** @hidden @internal */ +export const homeKey = 'Home' as const; +/** @hidden @internal */ +export const endKey = 'End' as const; +/** @hidden @internal */ +export const pageUpKey = 'PageUp' as const; +/** @hidden @internal */ +export const pageDownKey = 'PageDown' as const; +/** @hidden @internal */ +export const tabKey = 'Tab' as const; +/** @hidden @internal */ +export const deleteKey = 'Delete' as const; +/** @hidden @internal */ +export const backspaceKey = 'Backspace' as const; + +/** @hidden @internal */ +export const altKey = 'Alt' as const; +/** @hidden @internal */ +export const ctrlKey = 'Control' as const; +/** @hidden @internal */ +export const metaKey = 'Meta' as const; +/** @hidden @internal */ +export const shiftKey = 'Shift' as const; + +/** + * Whether the key of the event matches `key`, without regard to case. + * + * @remarks + * Prefer it over a direct comparison with `event.key`. A caller can hold a + * normalized lowercase name, while an event carries the canonical case. + */ +export function isKey(event: KeyboardEvent, key: string): boolean { + return event.key?.toLowerCase() === key.toLowerCase(); +} diff --git a/projects/igniteui-angular/core/src/public_api.ts b/projects/igniteui-angular/core/src/public_api.ts index 1eaeeca5d3c..873172322b7 100644 --- a/projects/igniteui-angular/core/src/public_api.ts +++ b/projects/igniteui-angular/core/src/public_api.ts @@ -7,6 +7,7 @@ export * from './core/types'; export * from './core/selection'; export * from './core/edit-provider'; export * from './core/touch'; +export * from './core/key-bindings'; export * from './core/ng-control-adapter'; // Grid actions tokens From fdee96bfb1586aaa604054001e0e1f759dbc2298 Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 19 Oct 2026 16:22:42 +0300 Subject: [PATCH 2/2] chore(tests): update import path --- .../core/src/core/key-bindings/key-bindings.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts index ea8d0caa3d7..cb3990dcf9e 100644 --- a/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts +++ b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts @@ -8,8 +8,8 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { IgxKeyBindingsService } from './key-bindings.service'; import { + IgxKeyBindingsService, altKey, arrowDown, arrowUp, @@ -19,7 +19,7 @@ import { isKey, shiftKey, spaceBar -} from './keys'; +} from 'igniteui-angular/core'; describe('IgxKeyBindingsService', () => { describe('Provided by a component', () => {