diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7b1897638..0662b463ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes for each version of this project will be documented in this - The `aria-live` region of the slides is now `off` only while the carousel is rotating automatically. Previously it stayed `off` whenever an `interval` was set, even after `stop()` was called or a non-looping carousel reached its last slide. - The automatic rotation of a non-looping carousel now ends on its last slide also when `pause` is `false`: `isPlaying` becomes `false` and `carouselPaused` is emitted. Previously the carousel kept playing on the last slide. - Touch gestures now follow the carousel orientation: a vertical carousel sets `touch-action: pan-x` instead of `pan-y`, drags across the carousel axis are left to the browser for scrolling and no longer resume a carousel paused by a tap, and a pan starts only after the touch moves at least 5px, the distance below which it is recognized as a tap. +- `IgxSplitterComponent` + - Arrow keys combined with `Shift`, `Alt` or `Meta`, and `Ctrl` + arrow keys combined with another modifier, no longer resize or collapse the panes. The splitter bar ignores them, and the browser default runs. Previously the splitter bar ignored the extra modifiers and handled these keys as the plain arrow keys or `Ctrl` + arrow keys. - `IgxStepperComponent` - In linear mode, the steps after the first invalid step that is not `optional` or `disabled` are disabled, wherever the active step is, as in the Ignite UI for Web Components stepper. Previously only the active step and the steps after it were checked, so an invalid `optional` active step disabled the steps after it, and an invalid step before the active one did not. The disabled steps are also recalculated when the `optional` state of a step changes, and the step activated with `navigateTo` or its `active` property is no longer displayed as disabled. - The vertical animations now use the default `animationDuration` of `320` milliseconds instead of `350`. `animationDuration` also falls back to `320` instead of `350` when it is set to a value that is not a positive number. @@ -27,6 +29,18 @@ All notable changes for each version of this project will be documented in this - Touch pans no longer leave slides partially moved or visible behind the current slide, for example when a pan is cancelled, reversed past the first or last slide of a non-looping carousel, released as a tap, or interrupted by removing slides or changing `gesturesSupport` or `vertical`. - Panning a carousel without slides no longer throws an error, and panning a carousel with a single slide no longer moves the slide against itself. - A slide activated through its `active` property now animates in the direction of its position, or in its own `direction` when one is set. +- `IgxDatePickerComponent` + - The default browser action of `Alt + Arrow Down` and `Alt + Arrow Up`, which open and close the calendar, is now prevented. +- `IgxDateRangePickerComponent` + - `Alt + Arrow Down` and `Alt + Arrow Up`, which open and close the calendar, no longer scroll the page. Previously their default browser action was not prevented. +- `IgxExpansionPanelComponent` + - `Alt + Arrow Down` and `Alt + Arrow Up` no longer expand or collapse the panel when its header is disabled. +- `IgxSplitterComponent` + - Keys other than the arrow keys, pressed on a splitter bar, now propagate, so for example `Escape` closes a dialog that contains the splitter. Previously the splitter bar stopped the propagation of every key. + - Holding `Ctrl` and an arrow key on a splitter bar now collapses or expands a pane once, instead of toggling it on every repeated key press. + - A splitter bar no longer loses the focus when one of its panes is collapsed, so the pane can be expanded again with `Ctrl` and an arrow key. + - A splitter bar next to a pane that is not `resizable` is now in the tab order, so the pane can be collapsed and expanded with `Ctrl` and an arrow key. The bar is removed from the tab order only when it can neither resize nor collapse its panes, that is when `nonCollapsible` is set and a pane is collapsed or not `resizable`. + - `Ctrl` and an arrow key no longer collapse or expand a pane when `nonCollapsible` is set. - `IgxStepperComponent` - When the steps are rendered with `@for`, no `ExpressionChangedAfterItHasBeenCheckedError` is thrown on init in development mode, and the active step and the step indicators are rendered on init in `OnPush` hosts. - `activeChange` is no longer emitted twice for the step activated on init, or for steps bound to `[active]="false"` that were not active. It is now emitted for the steps whose collapse animation is interrupted by changing `orientation`, `verticalAnimationType` or `horizontalAnimationType`. @@ -34,6 +48,8 @@ All notable changes for each version of this project will be documented in this - `next`, `prev` and focusing a step no longer throw when there is no active step, e.g. when all steps are initially disabled. In that case `activeStepChanging` is emitted with `oldIndex` set to `-1`. - `reset` keeps the active step visited, so its invalid state is displayed after moving to another step. - Setting `animationDuration` from code now applies it to the vertical animations as well. +- `IgxTimePickerComponent` + - The default browser action of `Alt + Arrow Down` and `Alt + Arrow Up`, which open and close the drop-down, is now prevented. ## 22.2.3 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..feb6743cb7b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.spec.ts @@ -0,0 +1,695 @@ +import { + ChangeDetectionStrategy, + Component, + NgZone, + PLATFORM_ID, + inject, + provideZoneChangeDetection, + provideZonelessChangeDetection +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + IgxKeyBindingsService, + altKey, + arrowDown, + arrowUp, + ctrlKey, + enterKey, + escapeKey, + isKey, + shiftKey, + spaceBar +} from 'igniteui-angular/core'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +describe('IgxKeyBindingsService', () => { + describe('Provided by a component', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + let keyBindings: IgxKeyBindingsService; + let handler: Mock<(event: KeyboardEvent) => void>; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [KeyBindingsHostComponent] }); + fixture = TestBed.createComponent(KeyBindingsHostComponent); + fixture.detectChanges(); + host = fixture.nativeElement; + keyBindings = fixture.componentInstance.keyBindings; + handler = vi.fn(); + }); + + 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).toHaveBeenCalledExactlyOnceWith(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).toHaveBeenCalledExactlyOnceWith(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.mock.calls[0][0].type).toBe('keydown'); + expect(handler.mock.calls[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).toBe(false); + }); + + 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 = vi.fn(() => skip); + keyBindings.configure({ skip: skipCallback }).set('k', handler); + + const inner = host.querySelector('.inner')!; + const event = dispatch(inner, 'keydown', 'k'); + expect(skipCallback).toHaveBeenCalledExactlyOnceWith(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 = vi.fn(() => 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).toBe(true); + }); + + it('should not prevent the default action when preventDefault is false', () => { + keyBindings.set('a', handler, { preventDefault: false }); + + expect(dispatch(host, 'keydown', 'a').defaultPrevented).toBe(false); + 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).toBe(true); + expect(dispatch(host, 'keyup', 'd').defaultPrevented).toBe(true); + }); + + it('should stop propagation when stopPropagation is true', () => { + const parentHandler = vi.fn(); + 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 = vi.fn(); + 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).toBe(false); + 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).toBe(false); + }); + }); + + 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.mock.calls[0][0].key).toBe(enterKey); + expect(handler.mock.calls[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 = vi.fn(); + 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(); + }); + + it('should run the keyup binding of a modifier on its release', () => { + keyBindings.set(altKey, handler, { triggers: ['keyup'] }); + + // The browser clears the flag of a modifier on its own keyup. + dispatch(host, 'keydown', altKey, { altKey: true }); + const keyup = dispatch(host, 'keyup', altKey); + + expect(handler).toHaveBeenCalledExactlyOnceWith(keyup); + }); + + it('should not run the keyup binding of a key when a modifier is released before it', () => { + keyBindings.set([ctrlKey, 'a'], vi.fn()).set('a', handler, { triggers: ['keyup'] }); + + dispatch(host, 'keydown', ctrlKey, { ctrlKey: true }); + dispatch(host, 'keydown', 'a', { ctrlKey: true }); + dispatch(host, 'keyup', ctrlKey); + expect(handler, 'A is still down').not.toHaveBeenCalled(); + + const keyup = dispatch(host, 'keyup', 'a'); + expect(handler).toHaveBeenCalledExactlyOnceWith(keyup); + }); + }); + + 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(); + }); + + it('should release the pressed keys when focus leaves the host', () => { + const inner = host.querySelector('.inner')!; + dispatch(inner, 'keydown', 'x'); + // The keyup of `x` fires outside the host, where the service does not listen. + inner.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: null })); + dispatch(inner, 'keydown', 'z'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should keep the pressed keys when focus moves inside the host', () => { + const inner = host.querySelector('.inner')!; + const input = host.querySelector('input')!; + keyBindings.configure({ skip: [] }); + dispatch(inner, 'keydown', 'x'); + inner.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: input })); + dispatch(input, 'keydown', 'z'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + 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 prevent the default of an ignored repeated keydown', () => { + keyBindings + .set('a', handler) + .set('b', handler, { preventDefault: false }); + + expect(dispatch(host, 'keydown', 'a', { repeat: true }).defaultPrevented, 'a held key keeps its default prevented') + .toBe(true); + dispatch(host, 'keyup', 'a'); + expect(dispatch(host, 'keydown', 'b', { repeat: true }).defaultPrevented).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should stop the propagation of an ignored repeated keydown when stopPropagation is true', () => { + const parentHandler = vi.fn(); + host.parentElement!.addEventListener('keydown', parentHandler); + keyBindings.set('a', handler, { stopPropagation: true }); + + dispatch(host, 'keydown', 'a', { repeat: true }); + + expect(parentHandler).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + host.parentElement!.removeEventListener('keydown', parentHandler); + }); + + 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 = vi.fn(); + const upHandler = vi.fn(); + 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 = vi.fn(); + 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).toHaveBeenCalledExactlyOnceWith(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 = vi.fn(); + 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).toBe(true); + 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 = vi.spyOn(window, 'addEventListener'); + TestBed.configureTestingModule({ + imports: [KeyBindingsHostComponent], + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }); + const fixture = TestBed.createComponent(KeyBindingsHostComponent); + const keyBindings = fixture.componentInstance.keyBindings; + const handler = vi.fn(); + 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', expect.anything(), expect.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)).toBe(true); + expect(isKey(event, 'arrowup')).toBe(true); + expect(isKey(event, arrowDown)).toBe(false); + expect(isKey(new KeyboardEvent('keydown', { key: 'Control' }), ctrlKey)).toBe(true); + }); +}); + +@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..604270adb34 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/key-bindings/key-bindings.service.ts @@ -0,0 +1,501 @@ +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. A modifier is not + * flagged on its own keyup, so `released` adds it. + */ +function getActiveModifiers(event: KeyboardEvent, released?: string): string[] { + return MODIFIER_ENTRIES + .filter(([name, property]) => event[property] || name === released) + .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 focus leaves the host or the window with a key down. + this._host.addEventListener('focusout', this._onHostFocusOut, { signal }); + this._document.defaultView?.addEventListener('blur', this._releasePressedKeys, { 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 _releasePressedKeys = (): void => { + this._pressedKeys.clear(); + }; + + /** Focus that moves inside the host keeps the keys: their keyup still reaches the host. */ + private readonly _onHostFocusOut = (event: FocusEvent): void => { + const next = event.relatedTarget as Node | null; + if (!next || !this._host.contains(next)) { + this._releasePressedKeys(); + } + }; + + 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, isModifier && isKeyup(event) ? key : undefined); + 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 (options.preventDefault) { + event.preventDefault(); + } + if (options.stopPropagation) { + event.stopPropagation(); + } + + // A held key keeps its default prevented, but the handler runs once. + if (!isKeyup(event) && event.repeat && !options.repeat) { + return; + } + + // 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 diff --git a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts index f3a7918f272..509b36c6feb 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts @@ -76,6 +76,38 @@ describe('Calendar Container', () => { detectChanges(); expect(fixture.componentInstance.doWork).toHaveBeenCalledWith(calendar); }); + + describe('Keyboard', () => { + let parentKeydown: ReturnType; + + beforeEach(() => { + parentKeydown = vi.fn(); + fixture.nativeElement.addEventListener('keydown', parentKeydown); + }); + + it('should emit calendarClose on ALT + UP ARROW and stop the event', () => { + vi.spyOn(container.calendarClose, 'emit'); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + + const altArrowUp = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true, cancelable: true }); + wrapper.dispatchEvent(altArrowUp); + + expect(container.calendarClose.emit).toHaveBeenCalledTimes(1); + expect(altArrowUp.defaultPrevented).toBe(true); + expect(parentKeydown, 'the picker should not close a second time').not.toHaveBeenCalled(); + }); + + it('should handle ALT + UP ARROW from an input in the picker actions', () => { + vi.spyOn(container.calendarClose, 'emit'); + container.pickerActions = fixture.componentInstance.actions(); + detectChanges(); + + const input = fixture.debugElement.query(By.css('.action-input')).nativeElement; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true, cancelable: true })); + + expect(container.calendarClose.emit).toHaveBeenCalledTimes(1); + }); + }); }); @Component({ @@ -84,6 +116,7 @@ describe('Calendar Container', () => { + `, changeDetection: ChangeDetectionStrategy.Eager, diff --git a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts index a8c9d624522..ebb5dff4009 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts @@ -4,9 +4,9 @@ import { ViewChild, Output, EventEmitter, - HostListener, HostBinding, - ChangeDetectionStrategy + ChangeDetectionStrategy, + inject } from '@angular/core'; import { IgxButtonDirective, IgxButtonType, IgxRippleDirective } from 'igniteui-angular/directives'; import { IgxCalendarComponent } from 'igniteui-angular/calendar'; @@ -17,7 +17,10 @@ import { CustomDateRange, PickerInteractionMode, IDateRangePickerResourceStrings, - IgxPickerActionsDirective + IgxPickerActionsDirective, + IgxKeyBindingsService, + altKey, + arrowUp } from 'igniteui-angular/core'; import { IgxPredefinedRangesAreaComponent } from '../../date-range-picker/predefined-ranges/predefined-ranges-area.component'; @@ -28,6 +31,7 @@ import { IgxPredefinedRangesAreaComponent } from '../../date-range-picker/predef styles: [':host {display: block;}'], templateUrl: 'calendar-container.component.html', changeDetection: ChangeDetectionStrategy.Eager, + providers: [IgxKeyBindingsService], imports: [ IgxButtonDirective, IgxRippleDirective, @@ -38,6 +42,8 @@ import { IgxPredefinedRangesAreaComponent } from '../../date-range-picker/predef ] }) export class IgxCalendarContainerComponent { + private readonly _keyBindings = inject(IgxKeyBindingsService); + @ViewChild(IgxCalendarComponent, { static: true }) public calendar!: IgxCalendarComponent; @@ -79,14 +85,12 @@ export class IgxCalendarContainerComponent { public mode: PickerInteractionMode = PickerInteractionMode.DropDown; public pickerActions!: IgxPickerActionsDirective; - @HostListener('keydown.alt.arrowup', ['$event']) - public onEscape(event: KeyboardEvent) { - event.preventDefault(); - - // Prevent the event from reaching IgxDatePickerComponent/IgxDateRangePickerComponent, + constructor() { + // Stop the event before it reaches IgxDatePickerComponent/IgxDateRangePickerComponent, // which also handle Alt+ArrowUp and would call close() a second time. - event.stopPropagation(); - this.calendarClose.emit(); + this._keyBindings + .configure({ skip: [] }) + .set([altKey, arrowUp], () => this.calendarClose.emit(), { stopPropagation: true }); } public get isReadonly() { diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts index 2ddeaff3892..ed600f151c1 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -7,7 +7,7 @@ import { IgxHintDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirect import { IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective } from '../../../calendar/src/public_api'; import { IgxCalendarContainerComponent } from './calendar-container/calendar-container.component'; import { IgxDatePickerComponent } from './date-picker.component'; -import { IgxOverlayService, OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings, WEEKDAYS } from 'igniteui-angular/core'; +import { IgxKeyBindingsService, IgxOverlayService, OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings, WEEKDAYS } from 'igniteui-angular/core'; import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, Injector, provideZonelessChangeDetection, QueryList, Renderer2, viewChild, ViewChild, ChangeDetectionStrategy, signal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../../../core/src/date-common/types'; @@ -21,8 +21,6 @@ import localeBg from "@angular/common/locales/bg"; import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const CSS_CLASS_DATE_PICKER = 'igx-date-picker'; - const DATE_PICKER_TOGGLE_ICON = 'calendar_today'; const DATE_PICKER_CLEAR_ICON = 'clear'; @@ -272,8 +270,8 @@ describe('IgxDatePicker', () => { expect(datePicker.collapsed).toBeTruthy(); expect(datePicker.isFocused).toBe(false); - const picker = fixture.debugElement.query(By.css(CSS_CLASS_DATE_PICKER)); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', picker, true); + const input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true, true); await vi.runAllTimersAsync(); detectChanges(); @@ -301,19 +299,79 @@ describe('IgxDatePicker', () => { vi.spyOn(datePicker.opened, 'emit'); expect(datePicker.collapsed).toBeTruthy(); - const picker = fixture.debugElement.query(By.css(CSS_CLASS_DATE_PICKER)); - UIInteractions.triggerEventHandlerKeyDown(' ', picker); + const input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + input.dispatchEvent(space); await vi.runAllTimersAsync(); detectChanges(); expect(datePicker.collapsed).toBeFalsy(); expect(datePicker.opening.emit).toHaveBeenCalledTimes(1); expect(datePicker.opened.emit).toHaveBeenCalledTimes(1); + expect(space.defaultPrevented, 'Space should not be typed in the input').toBe(true); // wait datepicker to get destroyed and test to cleanup await fixture.whenStable(); }); + it('should keep the default action of a held SPACE key prevented', async () => { + vi.spyOn(datePicker.opening, 'emit'); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + // Focus moves to the calendar when it has opened, so the repeats reach the input meanwhile. + const repeatedSpace = new KeyboardEvent('keydown', { key: ' ', repeat: true, bubbles: true, cancelable: true }); + input.dispatchEvent(space); + input.dispatchEvent(repeatedSpace); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(datePicker.collapsed).toBeFalsy(); + expect(datePicker.opening.emit).toHaveBeenCalledTimes(1); + expect(space.defaultPrevented).toBe(true); + expect(repeatedSpace.defaultPrevented, 'a held Space should not be typed in the input').toBe(true); + + await fixture.whenStable(); + }); + + it('should prevent the default action of ALT + DOWN/UP ARROW key', async () => { + const input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowDown); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(datePicker.collapsed).toBeFalsy(); + expect(altArrowDown.defaultPrevented, 'Alt+ArrowDown should be prevented').toBe(true); + + const calendarWrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + const altArrowUp = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true, cancelable: true }); + calendarWrapper.dispatchEvent(altArrowUp); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(datePicker.collapsed).toBeTruthy(); + expect(altArrowUp.defaultPrevented, 'Alt+ArrowUp should be prevented').toBe(true); + }); + + it('should not open the calendar with ALT + DOWN ARROW or SPACE key if readOnly is set to true', async () => { + datePicker.readOnly = true; + detectChanges(); + vi.spyOn(datePicker.opening, 'emit'); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')).nativeElement; + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowDown); + input.dispatchEvent(space); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.opening.emit).not.toHaveBeenCalled(); + expect(space.defaultPrevented, 'Space should not be typed in a read-only picker').toBe(true); + }); + it('should close the calendar with ESC', async () => { datePicker.mode = 'dropdown'; vi.spyOn(datePicker.closing, 'emit'); @@ -889,7 +947,8 @@ describe('IgxDatePicker', () => { nativeElement: { blur: vi.fn(), click: vi.fn(), - focus: vi.fn() + focus: vi.fn(), + addEventListener: vi.fn() } }; @@ -1023,6 +1082,7 @@ describe('IgxDatePicker', () => { { provide: Injector, useValue: mockInjector }, { provide: Renderer2, useValue: renderer2 }, { provide: ChangeDetectorRef, useValue: mockCdr }, + IgxKeyBindingsService, IgxDatePickerComponent ] }); diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts index 3745e44fa7c..33de26e9fba 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts @@ -7,7 +7,6 @@ import { ContentChild, EventEmitter, HostBinding, - HostListener, Injector, Input, OnDestroy, @@ -65,7 +64,12 @@ import { DatePart, isDateInRanges, I18N_FORMATTER, - NgControlAdapter + NgControlAdapter, + IgxKeyBindingsService, + altKey, + arrowDown, + arrowUp, + spaceBar } from 'igniteui-angular/core'; import { IDatePickerValidationFailedEventArgs } from './date-picker.common'; import { IgxIconComponent } from 'igniteui-angular/icon'; @@ -90,7 +94,8 @@ let NEXT_ID = 0; @Component({ providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: IgxDatePickerComponent, multi: true }, - { provide: NG_VALIDATORS, useExisting: IgxDatePickerComponent, multi: true } + { provide: NG_VALIDATORS, useExisting: IgxDatePickerComponent, multi: true }, + IgxKeyBindingsService ], selector: 'igx-date-picker', templateUrl: 'date-picker.component.html', @@ -117,6 +122,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr private platform = inject(PlatformUtil); private cdr = inject(ChangeDetectorRef); private _i18nFormatter = inject(I18N_FORMATTER); + private readonly _keyBindings = inject(IgxKeyBindingsService); /** * Gets/Sets whether the inactive dates will be hidden. @@ -519,6 +525,13 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr constructor() { super(); this.initLocale(); + + // The keys come from the inner input, so none are skipped. `open` checks disabled and readOnly. + this._keyBindings + .configure({ skip: [] }) + .set([altKey, arrowDown], () => this.open()) + .set([altKey, arrowUp], () => this.close()) + .set(spaceBar, () => this.open()); } /** @hidden @internal */ @@ -535,27 +548,6 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr return this._calendarContainer; } - /** @hidden @internal */ - @HostListener('keydown', ['$event']) - public onKeyDown(event: KeyboardEvent) { - switch (event.key) { - case this.platform.KEYMAP.ARROW_UP: - if (event.altKey) { - this.close(); - } - break; - case this.platform.KEYMAP.ARROW_DOWN: - if (event.altKey) { - this.open(); - } - break; - case this.platform.KEYMAP.SPACE: - event.preventDefault(); - this.open(); - break; - } - } - /** * Opens the picker's dropdown or dialog. * diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts index ae2366edc11..b6e1a8f365e 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts @@ -789,8 +789,8 @@ describe('IgxDateRangePicker', () => { expect(dateRange.collapsed).toBeTruthy(); expect(dateRange.isFocused).toBe(false); - const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + const input = singleInputElement.nativeElement; + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true, true); await vi.runAllTimersAsync(); detectChanges(); @@ -802,7 +802,7 @@ describe('IgxDateRangePicker', () => { expect(calendarWrapper.contains(document.activeElement), 'focus should move to calendar for KB nav').toBe(true); expect(dateRange.isFocused).toBe(true); - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', range, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input, true, true); await vi.runAllTimersAsync(); detectChanges(); @@ -839,6 +839,56 @@ describe('IgxDateRangePicker', () => { expect(dateRange.isFocused).toBe(true); }); + it('should prevent the default action of ALT + DOWN/UP ARROW key', async () => { + const input = singleInputElement.nativeElement; + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowDown); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(dateRange.collapsed).toBeFalsy(); + expect(altArrowDown.defaultPrevented, 'Alt+ArrowDown should not scroll the page').toBe(true); + + const altArrowUp = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowUp); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(dateRange.collapsed).toBeTruthy(); + expect(altArrowUp.defaultPrevented, 'Alt+ArrowUp should not scroll the page').toBe(true); + }); + + it('should not open the calendar with ALT alone after ALT + DOWN ARROW was released outside the picker', async () => { + const outlet = document.createElement('div'); + document.body.appendChild(outlet); + dateRange.overlaySettings = { outlet: new ElementRef(outlet) }; + detectChanges(); + + const input = singleInputElement.nativeElement; + input.focus(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true, true); + await vi.runAllTimersAsync(); + detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + const calendarWrapper = outlet.querySelector('.igx-calendar__wrapper'); + expect(calendarWrapper.contains(document.activeElement), 'focus should move to the calendar, outside the picker').toBe(true); + + // The keys are released in the calendar, so the picker sees no keyup. + calendarWrapper.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown', altKey: true, bubbles: true })); + dateRange.close(); + await vi.runAllTimersAsync(); + detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true, bubbles: true })); + await vi.runAllTimersAsync(); + detectChanges(); + expect(dateRange.collapsed, 'Alt alone should not open the calendar').toBeTruthy(); + + outlet.remove(); + }); + it('should not open calendar with ALT + DOWN ARROW key if disabled is set to true', async () => { fixture.componentInstance.mode = PickerInteractionMode.DropDown; fixture.componentInstance.disabled = true; @@ -855,6 +905,22 @@ describe('IgxDateRangePicker', () => { expect(dateRange.opening.emit).toHaveBeenCalledTimes(0); expect(dateRange.opened.emit).toHaveBeenCalledTimes(0); }); + + it('should not open calendar with ALT + DOWN ARROW key if readOnly is set to true', async () => { + dateRange.readOnly = true; + detectChanges(); + + vi.spyOn(dateRange.opening, 'emit'); + + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + singleInputElement.nativeElement.dispatchEvent(altArrowDown); + await vi.runAllTimersAsync(); + detectChanges(); + + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(0); + expect(altArrowDown.defaultPrevented, 'Alt+ArrowDown should not scroll the page').toBe(true); + }); }); it('should expand the calendar if the default icon (prefix) is clicked', async () => { @@ -1346,8 +1412,7 @@ describe('IgxDateRangePicker', () => { startInput.nativeElement.focus(); // await fixture.whenStable(); - const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', startInput.nativeElement, true, true); await vi.runAllTimersAsync(); detectChanges(); @@ -1368,8 +1433,8 @@ describe('IgxDateRangePicker', () => { expect(startInput.nativeElement.contains(document.activeElement), 'focus should return to the picker input').toBe(true); expect(dateRange.isFocused).toBe(true); - // reopen and close again - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + // reopen from the end input and close again + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', endInput.nativeElement, true, true); await vi.runAllTimersAsync(); detectChanges(); @@ -1394,8 +1459,7 @@ describe('IgxDateRangePicker', () => { vi.spyOn(dateRange.closed, 'emit'); expect(dateRange.collapsed).toBeTruthy(); - const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', startInput.nativeElement, true, true); await vi.runAllTimersAsync(); detectChanges(); expect(dateRange.collapsed).toBeFalsy(); @@ -1459,7 +1523,7 @@ describe('IgxDateRangePicker', () => { vi.spyOn(dateRange.opening, 'emit'); vi.spyOn(dateRange.opened, 'emit'); - // UIInteractions.triggerEventHandlerKeyDown('ArrowDown', calendar, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', startInput.nativeElement, true, true); await vi.runAllTimersAsync(); detectChanges(); diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts index 70db3ebb920..170251be318 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts @@ -7,7 +7,6 @@ import { ContentChildren, EventEmitter, HostBinding, - HostListener, Injector, Input, OnChanges, @@ -65,7 +64,11 @@ import { NgControlAdapter, PickerCalendarOrientation, THEME_TOKEN, - ThemeToken + ThemeToken, + IgxKeyBindingsService, + altKey, + arrowDown, + arrowUp } from 'igniteui-angular/core'; import { IgxCalendarContainerComponent } from '../date-picker/calendar-container/calendar-container.component'; import { PickerBaseDirective } from '../date-picker/picker-base.directive'; @@ -122,7 +125,8 @@ const SingleInputDatesConcatenationString = ' - '; encapsulation: ViewEncapsulation.None, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: IgxDateRangePickerComponent, multi: true }, - { provide: NG_VALIDATORS, useExisting: IgxDateRangePickerComponent, multi: true } + { provide: NG_VALIDATORS, useExisting: IgxDateRangePickerComponent, multi: true }, + IgxKeyBindingsService ], changeDetection: ChangeDetectionStrategy.Eager, imports: [ @@ -143,6 +147,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private _injector = inject(Injector); private _cdr = inject(ChangeDetectorRef); private _overlayService = inject(IgxOverlayService); + private readonly _keyBindings = inject(IgxKeyBindingsService); /** * The number of displayed month views. @@ -671,24 +676,12 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective constructor() { super(); this.initLocale(); - } - /** @hidden @internal */ - @HostListener('keydown', ['$event']) - /** @hidden @internal */ - public onKeyDown(event: KeyboardEvent): void { - switch (event.key) { - case this.platform.KEYMAP.ARROW_UP: - if (event.altKey) { - this.close(); - } - break; - case this.platform.KEYMAP.ARROW_DOWN: - if (event.altKey) { - this.open(); - } - break; - } + // The keys come from the inner inputs, so none are skipped. `open` checks disabled and readOnly. + this._keyBindings + .configure({ skip: [] }) + .set([altKey, arrowDown], () => this.open()) + .set([altKey, arrowUp], () => this.close()); } /** diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts index b091008fa01..8699953c84c 100644 --- a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts @@ -2,6 +2,7 @@ import { Component, ChangeDetectorRef, ElementRef, HostBinding, Input, EventEmit import { IgxExpansionPanelIconDirective } from './expansion-panel.directives'; import { IGX_EXPANSION_PANEL_COMPONENT, IgxExpansionPanelBase, IExpansionPanelCancelableEventArgs } from './expansion-panel.common'; import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxKeyBindingsService, altKey, arrowDown, arrowUp } from 'igniteui-angular/core'; /** * @hidden @@ -19,19 +20,16 @@ export type ExpansionPanelHeaderIconPosition = (typeof ExpansionPanelHeaderIconP templateUrl: 'expansion-panel-header.component.html', changeDetection: ChangeDetectionStrategy.Eager, host: { - '(keydown.Enter)': 'onAction($any($event))', - '(keydown.Space)': 'onAction($any($event))', - '(keydown.Spacebar)': 'onAction($any($event))', - '(click)': 'onAction($event)', - '(keydown.alt.arrowdown)': 'openPanel($event)', - '(keydown.alt.arrowup)': 'closePanel($event)' + '(click)': 'onAction($event)' }, + providers: [IgxKeyBindingsService], imports: [IgxIconComponent] }) export class IgxExpansionPanelHeaderComponent { public panel = inject(IGX_EXPANSION_PANEL_COMPONENT, { host: true }); public cdr = inject(ChangeDetectorRef); public elementRef = inject(ElementRef); + private readonly _keyBindings = inject(IgxKeyBindingsService); /** * Returns a reference to the `igx-expansion-panel-icon` element; @@ -156,7 +154,7 @@ export class IgxExpansionPanelHeaderComponent { /** * Gets/sets the whether the header is disabled - * When disabled, the header will not handle user events and will stop their propagation + * When disabled, the header ignores clicks and key presses, and stops the propagation of clicks * * ```typescript * const isDisabled = this.panel.header.disabled; @@ -214,6 +212,12 @@ export class IgxExpansionPanelHeaderComponent { constructor() { this.id = `${this.panel.id}-header`; + + this._keyBindings + .configure({ skip: () => this.disabled }) + .setActivateHandler((event) => this.onAction(event), { preventDefault: false, repeat: true }) + .set([altKey, arrowDown], (event) => this.openPanel(event)) + .set([altKey, arrowUp], (event) => this.closePanel(event)); } /** @@ -235,26 +239,22 @@ export class IgxExpansionPanelHeaderComponent { /** @hidden @internal */ public openPanel(event: KeyboardEvent) { - if (event.altKey) { - const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; - this.interaction.emit(eventArgs); - if (eventArgs.cancel === true) { - return; - } - this.panel.expand(event); + const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; + this.interaction.emit(eventArgs); + if (eventArgs.cancel === true) { + return; } + this.panel.expand(event); } /** @hidden @internal */ public closePanel(event: KeyboardEvent) { - if (event.altKey) { - const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; - this.interaction.emit(eventArgs); - if (eventArgs.cancel === true) { - return; - } - this.panel.collapse(event); + const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; + this.interaction.emit(eventArgs); + if (eventArgs.cancel === true) { + return; } + this.panel.collapse(event); } /** diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts index 7062cc4f050..13f21c888fe 100644 --- a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts @@ -560,10 +560,11 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); }); it('Should change panel expansion state on key interaction', async () => { - const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); - const spaceEvent = new KeyboardEvent('keydown', { key: 'Space' }); - const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true }); - const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true }); + // A key press is a new event; the key bindings ignore an event they already handled. + const enterEvent = () => new KeyboardEvent('keydown', { key: 'Enter' }); + const spaceEvent = () => new KeyboardEvent('keydown', { key: ' ' }); + const arrowUpEvent = () => new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true }); + const arrowDownEvent = () => new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true }); const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); fixture.detectChanges(); const panel = fixture.componentInstance.expansionPanel; @@ -579,7 +580,7 @@ describe('igxExpansionPanel', () => { vi.spyOn(header.interaction, 'emit'); verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); - panelHeader.dispatchEvent(enterEvent); + panelHeader.dispatchEvent(enterEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -587,7 +588,7 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(1); - panelHeader.dispatchEvent(spaceEvent); + panelHeader.dispatchEvent(spaceEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -595,7 +596,7 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(2); - panelHeader.dispatchEvent(enterEvent); + panelHeader.dispatchEvent(enterEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -604,7 +605,7 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(3); - panelHeader.dispatchEvent(spaceEvent); + panelHeader.dispatchEvent(spaceEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -612,14 +613,14 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(4); - panelHeader.dispatchEvent(arrowUpEvent); + panelHeader.dispatchEvent(arrowUpEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(5); - panelHeader.dispatchEvent(arrowDownEvent); + panelHeader.dispatchEvent(arrowDownEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -627,7 +628,7 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(6); - panelHeader.dispatchEvent(arrowDownEvent); + panelHeader.dispatchEvent(arrowDownEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -635,7 +636,7 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(7); - panelHeader.dispatchEvent(arrowUpEvent); + panelHeader.dispatchEvent(arrowUpEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -652,7 +653,7 @@ describe('igxExpansionPanel', () => { expect(panel.collapsed).toEqual(true); // cancel openening - panelHeader.dispatchEvent(arrowDownEvent); + panelHeader.dispatchEvent(arrowDownEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -670,7 +671,7 @@ describe('igxExpansionPanel', () => { expect(panel.collapsed).toEqual(false); // cancel closing - panelHeader.dispatchEvent(arrowUpEvent); + panelHeader.dispatchEvent(arrowUpEvent()); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -681,6 +682,32 @@ describe('igxExpansionPanel', () => { headerSub.unsubscribe(); }); + it('Should prevent the default action of Enter and Space only when the interaction is not cancelled', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + // A control projected into the header. + const projectedButton = document.createElement('button'); + fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER).appendChild(projectedButton); + const keydown = (key: string, repeat = false) => { + const event = new KeyboardEvent('keydown', { key, repeat, bubbles: true, cancelable: true }); + projectedButton.dispatchEvent(event); + return event; + }; + + const headerSub = header.interaction.subscribe((event) => { + event.cancel = true; + }); + expect(keydown('Enter').defaultPrevented, 'A cancelled Enter keeps its default action').toBe(false); + expect(keydown(' ').defaultPrevented, 'A cancelled Space keeps its default action').toBe(false); + expect(panel.collapsed).toBe(true); + headerSub.unsubscribe(); + + expect(keydown('Enter').defaultPrevented, 'An accepted Enter is prevented').toBe(true); + expect(keydown(' ').defaultPrevented, 'An accepted Space is prevented').toBe(true); + expect(keydown(' ', true).defaultPrevented, 'A held Space is prevented').toBe(true); + }); it('Should change panel expansion when using different methods', async () => { const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); fixture.detectChanges(); @@ -741,7 +768,7 @@ describe('igxExpansionPanel', () => { timesCollapsed++; verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); - panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' })); + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -801,7 +828,14 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(0); - panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' })); + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true })); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); @@ -837,6 +871,13 @@ describe('igxExpansionPanel', () => { verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); expect(header.interaction.emit).toHaveBeenCalledTimes(0); + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true })); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + header.disabled = false; await fixture.whenStable(); fixture.detectChanges(); diff --git a/projects/igniteui-angular/splitter/README.md b/projects/igniteui-angular/splitter/README.md index f55d76356c9..cd08bc3afa3 100644 --- a/projects/igniteui-angular/splitter/README.md +++ b/projects/igniteui-angular/splitter/README.md @@ -56,8 +56,8 @@ export class SplitLayoutComponent { ## Customization -- **Non-collapsible bars** – set `nonCollapsible` on the splitter or bar to hide expander affordances when panes must stay visible. -- **Keyboard support** – users can resize with arrow keys; combine with `ctrl` to collapse panes for accessibility. +- **Non-collapsible bars** – set `nonCollapsible` on the splitter or bar to hide expander affordances and turn off collapsing with `ctrl` + arrow keys when panes must stay visible. +- **Keyboard support** – users can resize with arrow keys; combine with `ctrl` to collapse and expand panes for accessibility. A splitter bar stays in the tab order unless it can neither resize nor collapse its panes. - **Drag constraints** – `minSize` and `maxSize` enforce boundaries while resizing, ensuring important content stays visible. - **Custom order** – bind `order` on panes or bars to change layout stacking in complex UIs. @@ -68,7 +68,7 @@ export class SplitLayoutComponent { | Name | Type | Default | Description | | --- | --- | --- | --- | | `type` | `SplitterType` | `SplitterType.Horizontal` | Orientation of the splitter (`Horizontal` renders a row layout, `Vertical` renders a column layout). | -| `nonCollapsible` | `boolean` | `false` | Hides collapse/expand affordances on splitter bars. | +| `nonCollapsible` | `boolean` | `false` | Hides collapse/expand affordances on splitter bars and turns off collapsing and expanding with `ctrl` + arrow keys. | ### IgxSplitterComponent outputs diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts b/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts index b60fe1ea900..111af66c3ed 100644 --- a/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts @@ -155,19 +155,19 @@ describe('IgxSplitter', () => { const pane2_originalSize = pane2.element.offsetHeight; const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); splitterBarComponent.nativeElement.focus(); - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(pane1.dragSize).toBe(pane1_originalSize - 10 + 'px'); expect(pane2.dragSize).toBe(pane2_originalSize + 10 + 'px'); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', splitterBarComponent.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(pane1.dragSize).toBe(pane1_originalSize + 10 + 'px'); expect(pane2.dragSize).toBe(pane2_originalSize - 10 + 'px'); pane2.resizable = false; - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(pane1.dragSize).toBe(pane1_originalSize + 10 + 'px'); expect(pane2.dragSize).toBe(pane2_originalSize - 10 + 'px'); @@ -184,19 +184,19 @@ describe('IgxSplitter', () => { const pane2_originalSize = pane2.element.offsetWidth; const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); splitterBarComponent.nativeElement.focus(); - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize - 10, 0); expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize + 10, 0); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponent.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 10, 0); expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 10, 0); pane1.resizable = false; - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponent.nativeElement); fixture.detectChanges(); expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 10, 0); expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 10, 0); @@ -211,16 +211,16 @@ describe('IgxSplitter', () => { expect(pane2.size).toBe('auto'); const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); splitterBarComponent.nativeElement.focus(); - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane1.collapsed).toBeTruthy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane1.collapsed).toBeFalsy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane2.collapsed).toBeTruthy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane2.collapsed).toBeFalsy(); }); @@ -234,20 +234,94 @@ describe('IgxSplitter', () => { expect(pane2.size).toBe('auto'); const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); splitterBarComponent.nativeElement.focus(); - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane1.collapsed).toBeTruthy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane1.collapsed).toBeFalsy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane2.collapsed).toBeTruthy(); - UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent, false, false, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', splitterBarComponent.nativeElement, true, false, false, true); fixture.detectChanges(); expect(pane2.collapsed).toBeFalsy(); }); + it('should keep the splitter bar focused after a pane is collapsed with the keyboard', () => { + const pane1 = splitter.panes.toArray()[0]; + const splitterBar: HTMLElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + splitterBar.focus(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', splitterBar, true, false, false, true); + fixture.detectChanges(); + + expect(pane1.collapsed).toBe(true); + expect(splitterBar.getAttribute('tabindex')).toBe('0'); + expect(document.activeElement).toBe(splitterBar); + }); + + it('should keep the splitter bar in the tab order unless it can neither resize nor collapse the panes', () => { + const splitterBar: HTMLElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + const cases = [ + { nonCollapsible: false, resizable: true, collapsed: false, tabindex: '0' }, + { nonCollapsible: false, resizable: false, collapsed: false, tabindex: '0' }, + { nonCollapsible: true, resizable: true, collapsed: false, tabindex: '0' }, + { nonCollapsible: true, resizable: false, collapsed: false, tabindex: null }, + // A collapsed pane cannot be resized. + { nonCollapsible: true, resizable: true, collapsed: true, tabindex: null } + ]; + + for (const { nonCollapsible, resizable, collapsed, tabindex } of cases) { + splitter.nonCollapsible = nonCollapsible; + splitter.panes.first.resizable = resizable; + splitter.panes.first.collapsed = collapsed; + fixture.detectChanges(); + expect(splitterBar.getAttribute('tabindex'), `nonCollapsible: ${nonCollapsible}, resizable: ${resizable}, collapsed: ${collapsed}`).toBe(tabindex); + } + }); + + it('should not collapse a pane with Ctrl + arrow keys when nonCollapsible is set', () => { + const pane1 = splitter.panes.toArray()[0]; + const splitterBar: HTMLElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + splitter.nonCollapsible = true; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', splitterBar, true, false, false, true); + fixture.detectChanges(); + + expect(pane1.collapsed).toBe(false); + }); + + it('should prevent the default action and stop the propagation of the arrow keys in either orientation', () => { + const splitterBar: HTMLElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + const parentListener = vi.fn(); + splitter.panes.first.element.parentElement.addEventListener('keydown', parentListener); + + for (const ctrlKey of [false, true]) { + for (const key of ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) { + const event = new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true }); + splitterBar.dispatchEvent(event); + expect(event.defaultPrevented, `${ctrlKey ? 'Ctrl+' : ''}${key} should be prevented`).toBe(true); + } + } + expect(parentListener).not.toHaveBeenCalled(); + }); + + it('should keep resizing the panes while an arrow key is held', () => { + const splitterBarDebug = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + const splitterBar: HTMLElement = splitterBarDebug.nativeElement; + vi.spyOn(splitterBarDebug.componentInstance.moving, 'emit'); + + splitterBar.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true })); + const repeated = new KeyboardEvent('keydown', { key: 'ArrowLeft', repeat: true, bubbles: true, cancelable: true }); + splitterBar.dispatchEvent(repeated); + + expect(splitterBarDebug.componentInstance.moving.emit).toHaveBeenCalledTimes(2); + expect(splitterBarDebug.componentInstance.moving.emit).toHaveBeenLastCalledWith(10); + expect(repeated.defaultPrevented).toBe(true); + }); + it('should preserve horizontal pane sizes after collapse and expand', () => { const [pane1, pane2] = splitter.panes.toArray(); const splitterBar = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).componentInstance; @@ -465,7 +539,7 @@ describe('IgxSplitter pane toggle', () => { expect(pane1.collapsed).toBeTruthy(); expect(pane1.resizable).toBeTruthy(); splitterBarComponentDebug.nativeElement.focus(); - UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponentDebug); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', splitterBarComponentDebug.nativeElement); fixture.detectChanges(); expect(pane1.size).toEqual(pane1_originalSize); diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts b/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts index f91d0a2e4b7..010c835342e 100644 --- a/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts @@ -5,7 +5,6 @@ import { ElementRef, EventEmitter, HostBinding, - HostListener, Input, Output, QueryList, @@ -23,6 +22,7 @@ import { IgxDragDirective, IgxDragIgnoreDirective } from 'igniteui-angular/directives'; +import { IgxKeyBindingsService, arrowDown, arrowLeft, arrowRight, arrowUp, ctrlKey } from 'igniteui-angular/core'; import { IgxSplitterPaneComponent } from './splitter-pane/splitter-pane.component'; /** @@ -397,9 +397,12 @@ export class IgxSplitterComponent implements AfterContentInit { selector: 'igx-splitter-bar', templateUrl: './splitter-bar.component.html', changeDetection: ChangeDetectionStrategy.Eager, + providers: [IgxKeyBindingsService], imports: [IgxDragDirective, IgxDragIgnoreDirective] }) export class IgxSplitBarComponent { + private readonly _keyBindings = inject(IgxKeyBindingsService); + /** * Set css class to the host element. */ @@ -431,7 +434,9 @@ export class IgxSplitBarComponent { */ @HostBinding('attr.tabindex') public get tabindex() { - return this.resizeDisallowed ? null : 0; + // The bar is focusable while it can resize or collapse the panes. A collapsed pane + // keeps a collapsible bar focusable, so the pane can be expanded with the keyboard. + return this.nonCollapsible && this.resizeDisallowed ? null : 0; } /** @@ -488,7 +493,20 @@ export class IgxSplitBarComponent { */ private startPoint!: number; - private interactionKeys = new Set('right down left up arrowright arrowdown arrowleft arrowup'.split(' ')); + constructor() { + // Every arrow is prevented and stops propagating in either orientation. + // A held arrow keeps resizing the panes; a held Ctrl + arrow collapses or expands a pane once. + this._keyBindings + .configure({ bindingDefaults: { stopPropagation: true } }) + .set(arrowUp, () => this.resizeWithKey(SplitterType.Vertical, 10), { repeat: true }) + .set(arrowDown, () => this.resizeWithKey(SplitterType.Vertical, -10), { repeat: true }) + .set(arrowLeft, () => this.resizeWithKey(SplitterType.Horizontal, 10), { repeat: true }) + .set(arrowRight, () => this.resizeWithKey(SplitterType.Horizontal, -10), { repeat: true }) + .set([ctrlKey, arrowUp], () => this.collapseWithKey(SplitterType.Vertical, false)) + .set([ctrlKey, arrowDown], () => this.collapseWithKey(SplitterType.Vertical, true)) + .set([ctrlKey, arrowLeft], () => this.collapseWithKey(SplitterType.Horizontal, false)) + .set([ctrlKey, arrowRight], () => this.collapseWithKey(SplitterType.Horizontal, true)); + } /** * @hidden @internal @@ -497,79 +515,6 @@ export class IgxSplitBarComponent { return this.siblings[0]?.collapsed && !this.siblings[1]?.collapsed; } - /** - * @hidden @internal - */ - @HostListener('keydown', ['$event']) - public keyEvent(event: KeyboardEvent) { - const key = event.key.toLowerCase(); - const ctrl = event.ctrlKey; - event.stopPropagation(); - if (this.interactionKeys.has(key)) { - event.preventDefault(); - } - switch (key) { - case 'arrowup': - case 'up': - if (this.type === SplitterType.Vertical) { - if (ctrl) { - this.onCollapsing(false); - break; - } - if (!this.resizeDisallowed) { - event.preventDefault(); - this.moveStart.emit(this.pane); - this.moving.emit(10); - } - } - break; - case 'arrowdown': - case 'down': - if (this.type === SplitterType.Vertical) { - if (ctrl) { - this.onCollapsing(true); - break; - } - if (!this.resizeDisallowed) { - event.preventDefault(); - this.moveStart.emit(this.pane); - this.moving.emit(-10); - } - } - break; - case 'arrowleft': - case 'left': - if (this.type === SplitterType.Horizontal) { - if (ctrl) { - this.onCollapsing(false); - break; - } - if (!this.resizeDisallowed) { - event.preventDefault(); - this.moveStart.emit(this.pane); - this.moving.emit(10); - } - } - break; - case 'arrowright': - case 'right': - if (this.type === SplitterType.Horizontal) { - if (ctrl) { - this.onCollapsing(true); - break; - } - if (!this.resizeDisallowed) { - event.preventDefault(); - this.moveStart.emit(this.pane); - this.moving.emit(-10); - } - } - break; - default: - break; - } - } - /** * @hidden @internal */ @@ -640,4 +585,17 @@ export class IgxSplitBarComponent { } target.toggle(); } + + private resizeWithKey(type: SplitterType, delta: number) { + if (this.type === type && !this.resizeDisallowed) { + this.moveStart.emit(this.pane); + this.moving.emit(delta); + } + } + + private collapseWithKey(type: SplitterType, next: boolean) { + if (this.type === type && !this.nonCollapsible) { + this.onCollapsing(next); + } + } } diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts index da918d2f84c..bf40351f83e 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts @@ -10,7 +10,7 @@ import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { advance, useFakeClock } from '../../../test-utils/helper-utils.spec'; import { IgxHintDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../../../input-group/src/public_api'; import { PickerInteractionMode } from '../../../core/src/date-common/types'; -import { PlatformUtil } from 'igniteui-angular/core'; +import { IgxKeyBindingsService, PlatformUtil } from 'igniteui-angular/core'; import { DatePart } from '../../../core/src/date-common/public_api'; import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; import { IgxItemListDirective, IgxTimeItemDirective } from './time-picker.directives'; @@ -105,7 +105,8 @@ describe('IgxTimePicker', () => { nativeElement: { blur: vi.fn().mockName("mockElement.blur"), click: vi.fn().mockName("mockElement.click"), - focus: vi.fn().mockName("mockElement.focus") + focus: vi.fn().mockName("mockElement.focus"), + addEventListener: vi.fn().mockName("mockElement.addEventListener") } }; mockControlInstance = { @@ -216,6 +217,7 @@ describe('IgxTimePicker', () => { { provide: Injector, useValue: mockInjector }, { provide: ChangeDetectorRef, useValue: mockCdr }, { provide: IGX_TIME_PICKER_COMPONENT, useExisting: IgxTimePickerComponent }, + IgxKeyBindingsService, IgxTimePickerComponent, PlatformUtil, IgxItemListDirective @@ -544,7 +546,7 @@ describe('IgxTimePicker', () => { }); it('should open the dropdown with `ArrowDown` + `Alt` key press and close it on outside click', async () => { - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', timePickerDebElement, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement, true, true); await fixture.whenStable(); fixture.detectChanges(); expect(timePicker.collapsed).toBeFalsy(); @@ -627,7 +629,7 @@ describe('IgxTimePicker', () => { }); it('should open/close the dropdown and keep the current selection on Space/Enter key press', async () => { - UIInteractions.triggerEventHandlerKeyDown(' ', timePickerDebElement); + UIInteractions.triggerKeyDownEvtUponElem(' ', fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement, true); await fixture.whenStable(); fixture.detectChanges(); expect(timePicker.collapsed).toBeFalsy(); @@ -667,7 +669,7 @@ describe('IgxTimePicker', () => { const selectedMinutes = fixture.componentInstance.date.getMinutes(); expect((dateTimeEditor.nativeElement.value).normalize('NFKC')).toEqual(`0${selectedHour}:${selectedMinutes} ${selectedAmpm}`); - UIInteractions.triggerEventHandlerKeyDown('Escape', timePickerDebElement); + UIInteractions.triggerKeyDownEvtUponElem('Escape', fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement, true); await fixture.whenStable(); fixture.detectChanges(); expect(timePicker.collapsed).toBeTruthy(); @@ -1700,7 +1702,7 @@ describe('IgxTimePicker', () => { expect(timePicker.collapsed).toBeTruthy(); expect(timePicker.isFocused).toBe(false); - UIInteractions.triggerEventHandlerKeyDown('ArrowDown', timePickerDebElement, true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement, true, true); await fixture.whenStable(); fixture.detectChanges(); @@ -1726,13 +1728,15 @@ describe('IgxTimePicker', () => { vi.spyOn(timePicker.opened, 'emit'); expect(timePicker.collapsed).toBeTruthy(); - UIInteractions.triggerEventHandlerKeyDown(' ', timePickerDebElement); + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement.dispatchEvent(space); await fixture.whenStable(); fixture.detectChanges(); expect(timePicker.collapsed).toBeFalsy(); expect(timePicker.opening.emit).toHaveBeenCalledTimes(1); expect(timePicker.opened.emit).toHaveBeenCalledTimes(1); + expect(space.defaultPrevented, 'Space should not be typed in the input').toBe(true); }); it('should close the dropdown with ESC', async () => { @@ -1745,12 +1749,80 @@ describe('IgxTimePicker', () => { fixture.detectChanges(); expect(timePicker.collapsed).toBeFalsy(); - UIInteractions.triggerKeyDownEvtUponElem('Escape', timePickerElement, true); + const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + timePickerElement.dispatchEvent(escape); await fixture.whenStable(); fixture.detectChanges(); expect(timePicker.collapsed).toBeTruthy(); expect(timePicker.closing.emit).toHaveBeenCalledTimes(1); expect(timePicker.closed.emit).toHaveBeenCalledTimes(1); + expect(escape.defaultPrevented, 'Escape keeps its default action').toBe(false); + }); + + it('should prevent the default action of ALT + DOWN/UP ARROW key in dropdown mode', async () => { + const input = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement; + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowDown); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeFalsy(); + expect(altArrowDown.defaultPrevented, 'Alt+ArrowDown should be prevented').toBe(true); + + const altArrowUp = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true, cancelable: true }); + input.dispatchEvent(altArrowUp); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeTruthy(); + expect(altArrowUp.defaultPrevented, 'Alt+ArrowUp should be prevented').toBe(true); + }); + + it('should not handle ALT + DOWN/UP ARROW key in dialog mode', async () => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + vi.spyOn(timePicker.opening, 'emit'); + + const altArrowDown = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true, cancelable: true }); + fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement.dispatchEvent(altArrowDown); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.opening.emit).not.toHaveBeenCalled(); + expect(altArrowDown.defaultPrevented, 'Alt+ArrowDown keeps its default in dialog mode').toBe(false); + }); + + it('should not open the dropdown with SPACE or ALT + DOWN ARROW key if readOnly is set to true', async () => { + timePicker.readOnly = true; + fixture.detectChanges(); + vi.spyOn(timePicker.opening, 'emit'); + + const input = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)).nativeElement; + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + input.dispatchEvent(space); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true, true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.opening.emit).not.toHaveBeenCalled(); + expect(space.defaultPrevented, 'Space should not be typed in a read-only picker').toBe(true); + }); + + it('should keep moving through the items while an ARROW key is held', async () => { + timePicker.open(); + await fixture.whenStable(); + fixture.detectChanges(); + + const hourList = hourColumn.injector.get(IgxItemListDirective); + const nextItem = vi.spyOn(hourList as any, 'nextItem'); + hourColumn.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + const repeated = new KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true, bubbles: true, cancelable: true }); + hourColumn.nativeElement.dispatchEvent(repeated); + + expect(nextItem).toHaveBeenCalledTimes(2); + expect(repeated.defaultPrevented).toBe(true); }); }); diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts index 3c24e927c41..f5fa7c4b585 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts @@ -14,7 +14,6 @@ import { Injector, PipeTransform, ChangeDetectorRef, - HostListener, booleanAttribute, inject, ChangeDetectionStrategy, @@ -65,6 +64,7 @@ import { IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; import { IgxToggleDirective } from 'igniteui-angular/directives'; import { ITimePickerResourceStrings, TimePickerResourceStringsEN } from 'igniteui-angular/core'; import { IBaseEventArgs, isEqual, isDate, PlatformUtil, IBaseCancelableBrowserEventArgs, NgControlAdapter } from 'igniteui-angular/core'; +import { IgxKeyBindingsService, altKey, arrowDown, arrowUp, escapeKey, spaceBar } from 'igniteui-angular/core'; import { IgxTextSelectionDirective } from 'igniteui-angular/directives'; import { TimeFormatPipe, TimeItemPipe } from './time-picker.pipes'; @@ -93,7 +93,8 @@ export interface IgxTimePickerValidationFailedEventArgs extends IBaseEventArgs { provide: NG_VALIDATORS, useExisting: IgxTimePickerComponent, multi: true - } + }, + IgxKeyBindingsService ], selector: 'igx-time-picker', styleUrl: 'time-picker.component.css', @@ -132,6 +133,7 @@ export class IgxTimePickerComponent extends PickerBaseDirective private _injector = inject(Injector); private platform = inject(PlatformUtil); private cdr = inject(ChangeDetectorRef); + private readonly _keyBindings = inject(IgxKeyBindingsService); /** * Sets the value of the `id` attribute. @@ -645,30 +647,15 @@ export class IgxTimePickerComponent extends PickerBaseDirective constructor() { super(); this.locale = this.locale || this._localeId; - } - /** @hidden @internal */ - @HostListener('keydown', ['$event']) - public onKeyDown(event: KeyboardEvent): void { - switch (event.key) { - case this.platform.KEYMAP.ARROW_UP: - if (event.altKey && this.isDropdown) { - this.close(); - } - break; - case this.platform.KEYMAP.ARROW_DOWN: - if (event.altKey && this.isDropdown) { - this.open(); - } - break; - case this.platform.KEYMAP.ESCAPE: - this.cancelButtonClick(); - break; - case this.platform.KEYMAP.SPACE: - this.open(); - event.preventDefault(); - break; - } + // Alt + arrows only work in drop-down mode; in dialog mode they are skipped and + // keep their default action. Escape keeps its default, as before. `open` checks disabled and readOnly. + this._keyBindings + .configure({ skip: (_target, event) => event.altKey && !this.isDropdown }) + .set([altKey, arrowDown], () => this.open()) + .set([altKey, arrowUp], () => this.close()) + .set(escapeKey, () => this.cancelButtonClick(), { preventDefault: false }) + .set(spaceBar, () => this.open()); } /** @hidden @internal */ diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts index e8ba822c151..0fa33563094 100644 --- a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts @@ -14,17 +14,30 @@ import { OnDestroy, OnInit } from '@angular/core'; -import { DateTimeUtil, I18N_FORMATTER, IgxTouchManager } from 'igniteui-angular/core'; +import { + DateTimeUtil, + I18N_FORMATTER, + IgxKeyBindingsService, + IgxTouchManager, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + enterKey, + escapeKey +} from 'igniteui-angular/core'; import { IgxTimePickerBase, IGX_TIME_PICKER_COMPONENT } from './time-picker.common'; /** @hidden */ @Directive({ selector: '[igxItemList]', - standalone: true + standalone: true, + providers: [IgxKeyBindingsService] }) export class IgxItemListDirective implements OnInit, OnDestroy { public timePicker = inject(IGX_TIME_PICKER_COMPONENT); private elementRef = inject(ElementRef); + private readonly _keyBindings = inject(IgxKeyBindingsService); private _gestures: IgxTouchManager | null = null; @@ -69,6 +82,17 @@ export class IgxItemListDirective implements OnInit, OnDestroy { return this.type === 'ampmList'; } + constructor() { + // A held arrow keeps moving through the items and the lists. + this._keyBindings + .set(arrowDown, () => this.nextItem(1), { repeat: true }) + .set(arrowUp, () => this.nextItem(-1), { repeat: true }) + .set(arrowRight, (event) => this.onKeydownArrowRight(event), { repeat: true }) + .set(arrowLeft, (event) => this.onKeydownArrowLeft(event), { repeat: true }) + .set(enterKey, () => this.timePicker.okButtonClick()) + .set(escapeKey, () => this.timePicker.cancelButtonClick()); + } + @HostListener('focus') public onFocus() { this.isActive = true; @@ -82,30 +106,7 @@ export class IgxItemListDirective implements OnInit, OnDestroy { /** * @hidden */ - @HostListener('keydown.arrowdown', ['$event']) - public onKeydownArrowDown(event: KeyboardEvent) { - event.preventDefault(); - - this.nextItem(1); - } - - /** - * @hidden - */ - @HostListener('keydown.arrowup', ['$event']) - public onKeydownArrowUp(event: KeyboardEvent) { - event.preventDefault(); - - this.nextItem(-1); - } - - /** - * @hidden - */ - @HostListener('keydown.arrowright', ['$event']) public onKeydownArrowRight(event: KeyboardEvent) { - event.preventDefault(); - const listName = (event.target as HTMLElement).className; if (listName.indexOf('hourList') !== -1 && this.timePicker.minuteList) { @@ -121,9 +122,7 @@ export class IgxItemListDirective implements OnInit, OnDestroy { /** * @hidden */ - @HostListener('keydown.arrowleft', ['$event']) public onKeydownArrowLeft(event: KeyboardEvent) { - event.preventDefault(); const listName = (event.target as HTMLElement).className; if (listName.indexOf('ampmList') !== -1 && this.timePicker.secondsList) { @@ -139,25 +138,6 @@ export class IgxItemListDirective implements OnInit, OnDestroy { } } - /** - * @hidden - */ - @HostListener('keydown.enter', ['$event']) - public onKeydownEnter(event: KeyboardEvent) { - event.preventDefault(); - this.timePicker.okButtonClick(); - } - - /** - * @hidden - */ - @HostListener('keydown.escape', ['$event']) - public onKeydownEscape(event: KeyboardEvent) { - event.preventDefault(); - - this.timePicker.cancelButtonClick(); - } - /** * @hidden */