`,
306 | );
307 | spectator.output('hotkey').subscribe(spyFcn);
308 | spyOnProperty(document.activeElement, 'nodeName', 'get').and.returnValue('DIV');
309 | spyOnProperty(document.activeElement as HTMLElement, 'isContentEditable', 'get').and.returnValue(true);
310 | spectator.dispatchKeyboardEvent(spectator.element.firstElementChild, 'keydown', 'g', spectator.element);
311 | spectator.dispatchKeyboardEvent(spectator.element.firstElementChild, 'keydown', 'n', spectator.element);
312 | await sleep(250);
313 | spectator.fixture.detectChanges();
314 | expect(spyFcn).not.toHaveBeenCalled();
315 | };
316 |
317 | it('should ignore hotkey when typing in a contentEditable element', () => {
318 | return shouldIgnoreOnContentEditableTest();
319 | });
320 |
321 | it('should ignore global hotkey when typing in a contentEditable element', () => {
322 | return shouldIgnoreOnContentEditableTest('isGlobal');
323 | });
324 |
325 | it('should trigger hotkey when typing in a contentEditable element', () => {
326 | const run = async () => {
327 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
328 | await sleep(250);
329 | const spyFcn = createSpy('subscribe', (...args) => {});
330 | spectator = createDirective(
331 | `
n'" [isSequence]="true" [hotkeysOptions]="{allowIn: ['CONTENTEDITABLE']}">
`,
332 | );
333 | spectator.output('hotkey').subscribe(spyFcn);
334 | spyOnProperty(document.activeElement, 'nodeName', 'get').and.returnValue('DIV');
335 | spyOnProperty(document.activeElement as HTMLElement, 'isContentEditable', 'get').and.returnValue(true);
336 | spectator.dispatchKeyboardEvent(spectator.element.firstElementChild, 'keydown', 'g', spectator.element);
337 | spectator.dispatchKeyboardEvent(spectator.element.firstElementChild, 'keydown', 'n', spectator.element);
338 | await sleep(250);
339 | spectator.fixture.detectChanges();
340 | expect(spyFcn).toHaveBeenCalled();
341 | };
342 |
343 | return run();
344 | });
345 |
346 | it('should trigger global sequence hotkey when element is not active', () => {
347 | const run = async () => {
348 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
349 | await sleep(250);
350 | const spyFcn = createSpy('subscribe', (...args) => {});
351 | spectator = createDirective(`
`);
352 | spectator.output('hotkey').subscribe(spyFcn);
353 | spectator.dispatchKeyboardEvent(document.firstElementChild, 'keydown', 'g', document.firstElementChild);
354 | spectator.dispatchKeyboardEvent(document.firstElementChild, 'keydown', 'n', document.firstElementChild);
355 | await sleep(250);
356 | spectator.fixture.detectChanges();
357 | expect(spyFcn).toHaveBeenCalled();
358 | };
359 |
360 | return run();
361 | });
362 |
363 | it('should register hotkey', () => {
364 | spectator = createDirective(`
p'" [isSequence]="true">
`);
365 | spectator.fixture.detectChanges();
366 | const hotkeysService = spectator.inject(HotkeysService);
367 | const hotkeys = hotkeysService.getHotkeys();
368 | expect(hotkeys.length).toBe(1);
369 | });
370 |
371 | it('should register proper key', () => {
372 | spectator = createDirective(`
q'" [isSequence]="true">
`);
373 | spectator.fixture.detectChanges();
374 | const provider = TestBed.inject(HotkeysService);
375 | const hotkey = provider.getHotkeys()[0];
376 | expect(hotkey.keys).toBe('g>q');
377 | });
378 |
379 | it('should register proper group', () => {
380 | spectator = createDirective(`
r'" [isSequence]="true" [hotkeysGroup]="'test group'">
`);
381 | spectator.fixture.detectChanges();
382 | const provider = TestBed.inject(HotkeysService);
383 | const hotkey = provider.getHotkeys()[0];
384 | expect(hotkey.group).toBe('test group');
385 | });
386 |
387 | it('should register proper description', () => {
388 | spectator = createDirective(
389 | `
s'" [isSequence]="true" [hotkeysDescription]="'test description'">
`,
390 | );
391 | spectator.fixture.detectChanges();
392 | const provider = TestBed.inject(HotkeysService);
393 | const hotkey = provider.getHotkeys()[0];
394 | expect(hotkey.description).toBe('test description');
395 | });
396 |
397 | it('should register proper options', () => {
398 | spectator = createDirective(
399 | `
t'" [isSequence]="true" [hotkeysOptions]="{trigger: 'keyup', showInHelpMenu: false, preventDefault: false}">
`,
400 | );
401 | spectator.fixture.detectChanges();
402 | const provider = TestBed.inject(HotkeysService);
403 | const hotkey = provider.getHotkeys()[0];
404 | expect(hotkey.preventDefault).toBe(false);
405 | expect(hotkey.trigger).toBe('keyup');
406 | expect(hotkey.showInHelpMenu).toBe(false);
407 | });
408 |
409 | it('should register proper with partial options', () => {
410 | spectator = createDirective(
411 | `
t'" [isSequence]="true" [hotkeysOptions]="{trigger: 'keyup'}">
`,
412 | );
413 | spectator.fixture.detectChanges();
414 | const provider = TestBed.inject(HotkeysService);
415 | const hotkey = provider.getHotkeys()[0];
416 | expect(hotkey.trigger).toBe('keyup');
417 | });
418 |
419 | it('should register proper global sequence hotkey', () => {
420 | spectator = createDirective(
421 | `
t'" isSequence isGlobal [hotkeysOptions]="{trigger: 'keyup'}">
`,
422 | );
423 | spectator.fixture.detectChanges();
424 | const provider = TestBed.inject(HotkeysService);
425 | const hotkey = provider.getHotkeys()[0];
426 | expect(hotkey.global).toBe(true);
427 | });
428 | });
429 |
430 | function sleep(ms: number): Promise
{
431 | return new Promise((resolve) => setTimeout(resolve, ms));
432 | }
433 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/lib/tests/hotkeys.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { DOCUMENT } from '@angular/common';
2 | import { TestBed } from '@angular/core/testing';
3 | import { Hotkey, HotkeysService } from '@ngneat/hotkeys';
4 | import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
5 |
6 | import * as Platform from '../utils/platform';
7 |
8 | import createSpy = jasmine.createSpy;
9 | // TODO: Use Spectator to trigger keyboard events
10 | describe('Service: Hotkeys', () => {
11 | let spectator: SpectatorService;
12 | const createService = createServiceFactory(HotkeysService);
13 |
14 | beforeEach(() => (spectator = createService()));
15 |
16 | it('should add shortcut', () => {
17 | spectator.service.addShortcut({ keys: 'a' }).subscribe();
18 | expect(spectator.service.getHotkeys().length).toBe(1);
19 | });
20 |
21 | it('should remove shortcut', () => {
22 | spectator.service.addShortcut({ keys: 'meta.a' });
23 | spectator.service.addShortcut({ keys: 'meta.b' });
24 | spectator.service.addShortcut({ keys: 'meta.c' });
25 | spectator.service.removeShortcuts(['meta.a', 'meta.b']);
26 | spectator.service.removeShortcuts('meta.c');
27 | expect(spectator.service.getHotkeys().length).toBe(0);
28 | });
29 |
30 | it('should unsubscribe shortcuts when removed', () => {
31 | const subscription = spectator.service.addShortcut({ keys: 'meta.a' }).subscribe();
32 | spectator.service.removeShortcuts('meta.a');
33 | expect(subscription.closed).toBe(true);
34 | });
35 |
36 | it('should delete hotkey and disposer when unsubscribed', () => {
37 | spectator.service.addShortcut({ keys: 'meta.a' }).subscribe().unsubscribe();
38 | expect(spectator.service.getHotkeys().length).toBe(0);
39 | });
40 |
41 | it('should listen to keydown', () => {
42 | const spyFcn = createSpy('subscribe', (e) => {});
43 | spectator.service.addShortcut({ keys: 'a' }).subscribe(spyFcn);
44 | fakeKeyboardPress('a');
45 | expect(spyFcn).toHaveBeenCalled();
46 | });
47 |
48 | it('should not listen to keydown if hotkeys are paused, should listen again when resumed', () => {
49 | const spyFcn = createSpy('subscribe', (e) => {});
50 | spectator.service.addShortcut({ keys: 'a' }).subscribe(spyFcn);
51 | spectator.service.pause();
52 | fakeKeyboardPress('a');
53 | expect(spyFcn).not.toHaveBeenCalled();
54 | spectator.service.resume();
55 | fakeKeyboardPress('a');
56 | expect(spyFcn).toHaveBeenCalled();
57 | });
58 |
59 | it('should listen to keyup', () => {
60 | const spyFcn = createSpy('subscribe', (e) => {});
61 | spectator.service.addShortcut({ keys: 'a', trigger: 'keyup' }).subscribe(spyFcn);
62 | fakeKeyboardPress('a', 'keyup');
63 | expect(spyFcn).toHaveBeenCalled();
64 | });
65 |
66 | it('should call callback', () => {
67 | const spyFcn = createSpy('subscribe', (...args) => {});
68 | spectator.service.addShortcut({ keys: 'a' }).subscribe();
69 | spectator.service.onShortcut(spyFcn);
70 | fakeKeyboardPress('a');
71 | expect(spyFcn).toHaveBeenCalled();
72 | });
73 |
74 | it('should not call callback when hotkeys are paused, should call again when resumed', () => {
75 | const spyFcn = createSpy('subscribe', (...args) => {});
76 | spectator.service.addShortcut({ keys: 'a' }).subscribe();
77 | spectator.service.onShortcut(spyFcn);
78 |
79 | spectator.service.pause();
80 | fakeKeyboardPress('a');
81 | expect(spyFcn).not.toHaveBeenCalled();
82 |
83 | spectator.service.resume();
84 | fakeKeyboardPress('a');
85 | expect(spyFcn).toHaveBeenCalled();
86 | });
87 |
88 | it('should honor target element', () => {
89 | const spyFcn = createSpy('subscribe', (...args) => {});
90 | spectator.service.addShortcut({ keys: 'a', element: document.body }).subscribe(spyFcn);
91 | fakeBodyKeyboardPress('a');
92 | expect(spyFcn).toHaveBeenCalled();
93 | });
94 |
95 | it('should change meta to ctrl', () => {
96 | spyOn(Platform, 'hostPlatform').and.returnValue('pc');
97 | spectator.service.addShortcut({ keys: 'meta.a' }).subscribe();
98 | const shortcuts = spectator.service.getShortcuts();
99 | expect(shortcuts[0].hotkeys[0].keys).toBe('control.a');
100 | });
101 |
102 | it('should exclude shortcut', () => {
103 | spyOn(Platform, 'hostPlatform').and.returnValue('pc');
104 | spectator.service.addShortcut({ keys: 'meta.a', showInHelpMenu: false }).subscribe();
105 | const shortcuts = spectator.service.getShortcuts();
106 | expect(shortcuts.length).toBe(0);
107 | });
108 |
109 | it('should use defaults', () => {
110 | spectator.service.addShortcut({ keys: 'a' }).subscribe();
111 | const hks = spectator.service.getHotkeys();
112 | expect(hks[0]).toEqual({
113 | element: document.documentElement,
114 | showInHelpMenu: true,
115 | allowIn: [],
116 | keys: 'a',
117 | trigger: 'keydown',
118 | group: undefined,
119 | description: undefined,
120 | preventDefault: true,
121 | });
122 | });
123 |
124 | it('should use options', () => {
125 | const options: Hotkey = {
126 | element: document.body,
127 | showInHelpMenu: false,
128 | allowIn: [],
129 | keys: 'a',
130 | trigger: 'keydown',
131 | group: 'test group',
132 | description: 'test description',
133 | preventDefault: false,
134 | };
135 |
136 | spectator.service.addShortcut(options).subscribe();
137 | const hks = spectator.service.getHotkeys();
138 | expect(hks[0]).toEqual(options);
139 | });
140 |
141 | it('should return shortcut', () => {
142 | const options: Hotkey = {
143 | showInHelpMenu: true,
144 | keys: 'a',
145 | group: 'test group',
146 | description: 'test description',
147 | };
148 |
149 | spectator.service.addShortcut(options).subscribe();
150 | const scts = spectator.service.getShortcuts();
151 | expect(scts[0]).toEqual({
152 | group: 'test group',
153 | hotkeys: [
154 | {
155 | keys: 'a',
156 | description: 'test description',
157 | },
158 | ],
159 | });
160 | });
161 |
162 | it('should listen to up', () => {
163 | const spyFcn = createSpy('subscribe', (e) => {});
164 | spectator.service.addShortcut({ keys: 'up' }).subscribe(spyFcn);
165 | fakeKeyboardPress('ArrowUp');
166 | expect(spyFcn).toHaveBeenCalled();
167 | });
168 | });
169 |
170 | describe('Service: Sequence Hotkeys', () => {
171 | let spectator: SpectatorService;
172 | const createService = createServiceFactory(HotkeysService);
173 |
174 | beforeEach(() => (spectator = createService()));
175 |
176 | it('should add shortcut', () => {
177 | spectator.service.addSequenceShortcut({ keys: 'g>a' }).subscribe();
178 | expect(spectator.service.getHotkeys().length).toBe(1);
179 | });
180 |
181 | it('should remove shortcut', () => {
182 | spectator.service.addSequenceShortcut({ keys: 'g>b' });
183 | spectator.service.addSequenceShortcut({ keys: 'g>c' });
184 | spectator.service.addSequenceShortcut({ keys: 'g>d' });
185 | spectator.service.removeShortcuts(['g>b', 'g>c']);
186 | spectator.service.removeShortcuts('g>d');
187 | expect(spectator.service.getHotkeys().length).toBe(0);
188 | });
189 |
190 | it('should unsubscribe shortcuts when removed', () => {
191 | const subscription = spectator.service.addSequenceShortcut({ keys: 'g>e' }).subscribe();
192 | spectator.service.removeShortcuts('g>e');
193 | expect(subscription.closed).toBe(true);
194 | });
195 |
196 | it('should delete hotkey and disposer when unsubscribed', () => {
197 | spectator.service.addSequenceShortcut({ keys: 'g>t' }).subscribe().unsubscribe();
198 | expect(spectator.service.getHotkeys().length).toBe(0);
199 | });
200 |
201 | it('should allow key sequence within allotted time', () => {
202 | const run = async () => {
203 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
204 | spectator.service.setSequenceDebounce(200);
205 | await sleep(250);
206 | const spyFcn = createSpy('subscribe', (e) => {});
207 | spectator.service.addSequenceShortcut({ keys: 'g>z' }).subscribe(spyFcn);
208 | await fakeKeyboardSequencePress(['g', 'z'], 'keydown', 100);
209 | await sleep(250);
210 | expect(spyFcn).toHaveBeenCalled();
211 | };
212 |
213 | return run();
214 | });
215 |
216 | it('should not allow key sequence outside allotted time', () => {
217 | const run = async () => {
218 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
219 | spectator.service.setSequenceDebounce(200);
220 | await sleep(250);
221 | const spyFcn = createSpy('subscribe', (e) => {});
222 | spectator.service.addSequenceShortcut({ keys: 'g>z' }).subscribe(spyFcn);
223 | await fakeKeyboardSequencePress(['g', 'z'], 'keydown', 250);
224 | await sleep(200);
225 | expect(spyFcn).not.toHaveBeenCalled();
226 | };
227 |
228 | return run();
229 | });
230 |
231 | it('should listen to keydown', () => {
232 | const run = async () => {
233 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
234 | await sleep(250);
235 | const spyFcn = createSpy('subscribe', (e) => {});
236 | spectator.service.addSequenceShortcut({ keys: 'g>f' }).subscribe(spyFcn);
237 | fakeKeyboardSequencePress(['g', 'f']);
238 | await sleep(250);
239 | expect(spyFcn).toHaveBeenCalled();
240 | };
241 |
242 | return run();
243 | });
244 |
245 | it('should listen to keyup', () => {
246 | const run = async () => {
247 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
248 | await sleep(250);
249 | const spyFcn = createSpy('subscribe', (e) => {});
250 | spectator.service.addSequenceShortcut({ keys: 'g>g', trigger: 'keyup' }).subscribe(spyFcn);
251 | fakeKeyboardSequencePress(['g', 'g'], 'keyup');
252 | await sleep(250);
253 | expect(spyFcn).toHaveBeenCalled();
254 | };
255 |
256 | return run();
257 | });
258 |
259 | it('should call callback', () => {
260 | const run = async () => {
261 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
262 | await sleep(250);
263 | const spyFcn = createSpy('subscribe', (...args) => {});
264 | spectator.service.addSequenceShortcut({ keys: 'g>h' }).subscribe();
265 | spectator.service.onShortcut(spyFcn);
266 | fakeKeyboardSequencePress(['g', 'h']);
267 | await sleep(250);
268 | expect(spyFcn).toHaveBeenCalled();
269 | };
270 |
271 | return run();
272 | });
273 |
274 | it('should honor target element', () => {
275 | const run = async () => {
276 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
277 | await sleep(250);
278 | const spyFcn = createSpy('subscribe', (...args) => {});
279 | spectator.service.addSequenceShortcut({ keys: 'g>i', element: document.body }).subscribe(spyFcn);
280 | fakeBodyKeyboardSequencePress(['g', 'i']);
281 | await sleep(250);
282 | expect(spyFcn).toHaveBeenCalled();
283 | };
284 |
285 | return run();
286 | });
287 |
288 | it('should change meta to ctrl', () => {
289 | spyOn(Platform, 'hostPlatform').and.returnValue('pc');
290 | spectator.service.addSequenceShortcut({ keys: 'meta.a>meta.b' }).subscribe();
291 | const shortcuts = spectator.service.getShortcuts();
292 | expect(shortcuts[0].hotkeys[0].keys).toBe('control.a>control.b');
293 | });
294 |
295 | it('should exclude shortcut', () => {
296 | spyOn(Platform, 'hostPlatform').and.returnValue('pc');
297 | spectator.service.addSequenceShortcut({ keys: 'meta.c>meta.d', showInHelpMenu: false }).subscribe();
298 | const shortcuts = spectator.service.getShortcuts();
299 | expect(shortcuts.length).toBe(0);
300 | });
301 |
302 | it('should use defaults', () => {
303 | spectator.service.addSequenceShortcut({ keys: 'g>j' }).subscribe();
304 | const hks = spectator.service.getHotkeys();
305 | expect(hks[0]).toEqual({
306 | element: document.documentElement,
307 | showInHelpMenu: true,
308 | allowIn: [],
309 | keys: 'g>j',
310 | trigger: 'keydown',
311 | group: undefined,
312 | description: undefined,
313 | preventDefault: true,
314 | });
315 | });
316 |
317 | it('should use options', () => {
318 | const options: Hotkey = {
319 | element: document.body,
320 | showInHelpMenu: false,
321 | allowIn: [],
322 | keys: 'g>k',
323 | trigger: 'keydown',
324 | group: 'test group',
325 | description: 'test description',
326 | preventDefault: false,
327 | };
328 |
329 | spectator.service.addSequenceShortcut(options).subscribe();
330 | const hks = spectator.service.getHotkeys();
331 | expect(hks[0]).toEqual(options);
332 | });
333 |
334 | it('should return shortcut', () => {
335 | const options: Hotkey = {
336 | showInHelpMenu: true,
337 | keys: 'g>l',
338 | group: 'test group',
339 | description: 'test description',
340 | };
341 |
342 | spectator.service.addSequenceShortcut(options).subscribe();
343 | const scts = spectator.service.getShortcuts();
344 | expect(scts[0]).toEqual({
345 | group: 'test group',
346 | hotkeys: [
347 | {
348 | keys: 'g>l',
349 | description: 'test description',
350 | },
351 | ],
352 | });
353 | });
354 |
355 | it('should listen to up', () => {
356 | const run = async () => {
357 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
358 | await sleep(250);
359 | const spyFcn = createSpy('subscribe', (e) => {});
360 | spectator.service.addSequenceShortcut({ keys: 'up>up' }).subscribe(spyFcn);
361 | fakeKeyboardSequencePress(['ArrowUp', 'ArrowUp']);
362 | await sleep(250);
363 | expect(spyFcn).toHaveBeenCalled();
364 | };
365 |
366 | return run();
367 | });
368 |
369 | it('should call callback after clearing and adding sequence shortcut', () => {
370 | const run = async () => {
371 | // * Need to space out time to prevent other test keystrokes from interfering with sequence
372 | await sleep(250);
373 | const spyFcn = createSpy('subscribe', (...args) => {});
374 | spectator.service.addSequenceShortcut({ keys: 'g>h' }).subscribe();
375 | spectator.service.removeShortcuts('g>h');
376 | spectator.service.addSequenceShortcut({ keys: 'g>j' }).subscribe();
377 | spectator.service.onShortcut(spyFcn);
378 | fakeKeyboardSequencePress(['g', 'j']);
379 | await sleep(250);
380 | expect(spyFcn).toHaveBeenCalled();
381 | };
382 |
383 | return run();
384 | });
385 |
386 | it('should add sequence shortcut', () => {
387 | spectator.service.addSequenceShortcut({ keys: 'g>i' }).subscribe();
388 | expect(spectator.service.getHotkeys().length).toBe(1);
389 | });
390 | });
391 |
392 | function fakeKeyboardPress(key: string, type = 'keydown') {
393 | const html = TestBed.inject(DOCUMENT).documentElement;
394 | html.dispatchEvent(new KeyboardEvent(type, { key }));
395 | }
396 |
397 | function fakeBodyKeyboardPress(key: string, type = 'keydown') {
398 | const html = TestBed.inject(DOCUMENT).body;
399 | html.dispatchEvent(new KeyboardEvent(type, { key }));
400 | }
401 |
402 | async function fakeKeyboardSequencePress(keys: string[], type = 'keydown', wait = 0) {
403 | const html = TestBed.inject(DOCUMENT).documentElement;
404 | for (let i = 0; i < keys.length; i++) {
405 | const key = keys[i];
406 | html.dispatchEvent(new KeyboardEvent(type, { key }));
407 | if (wait > 0 && i < keys.length - 1) {
408 | await sleep(wait);
409 | }
410 | }
411 | }
412 |
413 | function fakeBodyKeyboardSequencePress(keys: string[], type = 'keydown') {
414 | const html = TestBed.inject(DOCUMENT).body;
415 | keys.forEach((key: string) => html.dispatchEvent(new KeyboardEvent(type, { key })));
416 | }
417 |
418 | function sleep(ms: number): Promise {
419 | return new Promise((resolve) => setTimeout(resolve, ms));
420 | }
421 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/lib/utils/alias.ts:
--------------------------------------------------------------------------------
1 | type ModifierKey =
2 | | 'shift'
3 | | 'control'
4 | | 'alt'
5 | | 'meta'
6 | | 'altleft'
7 | | 'backspace'
8 | | 'tab'
9 | | 'left'
10 | | 'right'
11 | | 'up'
12 | | 'down'
13 | | 'enter'
14 | | 'space'
15 | | 'escape';
16 |
17 | export type CustomAliases = { [key in ModifierKey]?: string };
18 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/lib/utils/array.ts:
--------------------------------------------------------------------------------
1 | export function coerceArray(params: any | any[]) {
2 | return Array.isArray(params) ? params : [params];
3 | }
4 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/lib/utils/platform.ts:
--------------------------------------------------------------------------------
1 | export type Platform = 'apple' | 'pc';
2 |
3 | export function hostPlatform(): Platform {
4 | const appleDevices = ['Mac', 'iPhone', 'iPad'];
5 | return appleDevices.some((d) => navigator.userAgent.includes(d)) ? 'apple' : 'pc';
6 | }
7 |
8 | export function normalizeKeys(keys: string, platform: Platform): string {
9 | const transformMap = {
10 | up: 'ArrowUp',
11 | down: 'ArrowDown',
12 | left: 'ArrowLeft',
13 | right: 'ArrowRight',
14 | };
15 |
16 | function transform(key: string): string {
17 | if (platform === 'pc' && key === 'meta') {
18 | key = 'control';
19 | }
20 |
21 | if (key in transformMap) {
22 | key = transformMap[key];
23 | }
24 |
25 | return key;
26 | }
27 |
28 | return keys
29 | .toLowerCase()
30 | .split('>')
31 | .map((s) => s.split('.').map(transform).join('.'))
32 | .join('>');
33 | }
34 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/public-api.ts:
--------------------------------------------------------------------------------
1 | export * from './lib/hotkeys.service';
2 | export * from './lib/hotkeys.directive';
3 | export * from './lib/hotkeys-help/hotkeys-help.component';
4 | export * from './lib/hotkeys-shortcut.pipe';
5 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import 'zone.js';
4 | import 'zone.js/testing';
5 | import { getTestBed } from '@angular/core/testing';
6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
7 |
8 | // First, initialize the Angular testing environment.
9 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
10 | teardown: { destroyAfterEach: false },
11 | });
12 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../../out-tsc/lib",
5 | "declarationMap": true,
6 | "declaration": true,
7 | "inlineSources": true,
8 | "types": [],
9 | "lib": ["dom", "es2018"]
10 | },
11 | "angularCompilerOptions": {
12 | "skipTemplateCodegen": true,
13 | "strictMetadataEmit": true,
14 | "enableResourceInlining": true
15 | },
16 | "exclude": ["src/test.ts", "**/*.spec.ts"]
17 | }
18 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/tsconfig.lib.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.lib.json",
3 | "compilerOptions": {
4 | "declarationMap": false
5 | },
6 | "angularCompilerOptions": {
7 | "compilationMode": "partial"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "module": "CommonJS",
5 | "outDir": "../../../out-tsc/spec",
6 | "types": ["jasmine", "node"]
7 | },
8 | "files": ["src/test.ts"],
9 | "include": ["**/*.spec.ts", "**/*.d.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/projects/ngneat/hotkeys/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tslint.json",
3 | "rules": {
4 | "directive-selector": [true, "attribute", "lib", "camelCase"],
5 | "component-selector": [true, "element", "lib", "kebab-case"]
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/app.component.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 2rem;
3 | max-width: 400px;
4 | margin: auto;
5 | display: flex;
6 | flex-direction: column;
7 | gap: 1rem;
8 | }
9 |
10 | .grid-container {
11 | display: grid;
12 | grid-template-columns: repeat(2, 1fr);
13 | gap: 1rem;
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
37 |
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { AfterViewInit, Component, ElementRef, inject, viewChild } from '@angular/core';
2 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
3 | import { HotkeysDirective, HotkeysHelpComponent, HotkeysService } from '@ngneat/hotkeys';
4 |
5 | @Component({
6 | standalone: true,
7 | selector: 'app-root',
8 | imports: [HotkeysDirective],
9 | templateUrl: './app.component.html',
10 | styleUrls: ['./app.component.css'],
11 | })
12 | export class AppComponent implements AfterViewInit {
13 | private hotkeys = inject(HotkeysService);
14 | private modalService = inject(NgbModal);
15 | input = viewChild>('input');
16 | input2 = viewChild>('input2');
17 | input3 = viewChild>('input3');
18 | container = viewChild>('container');
19 | isActive = this.hotkeys.isActive;
20 |
21 | ngAfterViewInit(): void {
22 | this.hotkeys.onShortcut((event, keys) => console.log(keys));
23 |
24 | const helpFcn: () => void = () => {
25 | const ref = this.modalService.open(HotkeysHelpComponent, { size: 'lg' });
26 | ref.componentInstance.title = 'Custom Shortcuts Title';
27 | ref.componentInstance.dismiss.subscribe(() => ref.close());
28 | };
29 |
30 | this.hotkeys.registerHelpModal(helpFcn);
31 |
32 | this.hotkeys
33 | .addSequenceShortcut({
34 | keys: 'g>t',
35 | description: 'In Code Test',
36 | preventDefault: false,
37 | group: 'Sequencing',
38 | })
39 | .subscribe((e) => {
40 | console.log('Test Sequence:', e);
41 | });
42 |
43 | this.hotkeys
44 | .addSequenceShortcut({
45 | keys: 'control.b>control.,',
46 | description: 'Expand All',
47 | preventDefault: false,
48 | group: 'Sequencing',
49 | })
50 | .subscribe((e) => {
51 | console.log('Test Sequence:', e);
52 | });
53 |
54 | this.hotkeys
55 | .addSequenceShortcut({
56 | keys: 'r>s',
57 | description: 'Remove this sequence',
58 | preventDefault: false,
59 | group: 'Sequencing',
60 | })
61 | .subscribe(() => {
62 | this.hotkeys.removeShortcuts('r>s');
63 | });
64 |
65 | this.hotkeys
66 | .addShortcut({
67 | keys: 'meta.g',
68 | element: this.input().nativeElement,
69 | description: 'Go to Code',
70 | allowIn: ['INPUT'],
71 | preventDefault: false,
72 | group: 'Repositories',
73 | })
74 | .subscribe((e) => console.log('Go to Code', e));
75 |
76 | this.hotkeys
77 | .addShortcut({
78 | keys: 'control.f',
79 | element: this.input2().nativeElement,
80 | description: 'Go to Issues',
81 | group: 'Repositories',
82 | })
83 | .subscribe((e) => console.log('Go to Issues', e));
84 |
85 | this.hotkeys
86 | .addShortcut({
87 | keys: 'shift.r',
88 | description: 'Jump to line',
89 | group: 'Source code browsing',
90 | })
91 | .subscribe((e) => console.log('Source code browsing', e));
92 |
93 | this.hotkeys
94 | .addShortcut({
95 | keys: 'meta.k',
96 | description: 'Go to notifications',
97 | group: 'Site-wide shortcuts',
98 | })
99 | .subscribe((e) => console.log('Go to notifications', e));
100 | }
101 |
102 | handleHotkey(e: KeyboardEvent) {
103 | console.log('New document hotkey', e);
104 | }
105 |
106 | pauseHotkeys() {
107 | this.hotkeys.pause();
108 | }
109 |
110 | resumeHotkeys() {
111 | this.hotkeys.resume();
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngneat/hotkeys/c61076b79f33823c749065016c0eac13c3161167/src/assets/.gitkeep
--------------------------------------------------------------------------------
/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true
3 | };
4 |
--------------------------------------------------------------------------------
/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // This file can be replaced during build by using the `fileReplacements` array.
2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
3 | // The list of file replacements can be found in `angular.json`.
4 |
5 | export const environment = {
6 | production: false
7 | };
8 |
9 | /*
10 | * For easier debugging in development mode, you can import the following file
11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
12 | *
13 | * This import should be commented out in production mode because it will have a negative impact
14 | * on performance if an error is thrown.
15 | */
16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
17 |
--------------------------------------------------------------------------------
/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngneat/hotkeys/c61076b79f33823c749065016c0eac13c3161167/src/favicon.ico
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Hotkeys
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode, importProvidersFrom } from '@angular/core';
2 |
3 | import { environment } from './environments/environment';
4 | import { bootstrapApplication, BrowserModule } from '@angular/platform-browser';
5 | import { AppComponent } from './app/app.component';
6 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
7 | import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
8 |
9 | if (environment.production) {
10 | enableProdMode();
11 | }
12 |
13 | bootstrapApplication(AppComponent, {
14 | providers: [importProvidersFrom(BrowserModule, BrowserAnimationsModule, NgbModalModule)],
15 | }).catch((err) => console.error(err));
16 |
--------------------------------------------------------------------------------
/src/polyfills.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file includes polyfills needed by Angular and is loaded before the app.
3 | * You can add your own extra polyfills to this file.
4 | *
5 | * This file is divided into 2 sections:
6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
8 | * file.
9 | *
10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
13 | *
14 | * Learn more in https://angular.io/guide/browser-support
15 | */
16 |
17 | /***************************************************************************************************
18 | * BROWSER POLYFILLS
19 | */
20 |
21 | /**
22 | * By default, zone.js will patch all possible macroTask and DomEvents
23 | * user can disable parts of macroTask/DomEvents patch by setting following flags
24 | * because those flags need to be set before `zone.js` being loaded, and webpack
25 | * will put import in the top of bundle, so user need to create a separate file
26 | * in this directory (for example: zone-flags.ts), and put the following flags
27 | * into that file, and then add the following code before importing zone.js.
28 | * import './zone-flags';
29 | *
30 | * The flags allowed in zone-flags.ts are listed here.
31 | *
32 | * The following flags will work for all browsers.
33 | *
34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
37 | *
38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge
40 | *
41 | * (window as any).__Zone_enable_cross_context_check = true;
42 | *
43 | */
44 |
45 | /***************************************************************************************************
46 | * Zone JS is required by default for Angular itself.
47 | */
48 | import 'zone.js'; // Included with Angular CLI.
49 |
50 | /***************************************************************************************************
51 | * APPLICATION IMPORTS
52 | */
53 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | /* You can add global styles to this file, and also import other style files */
2 |
3 | html,
4 | body {
5 | height: 100%;
6 | }
7 | body {
8 | margin: 0;
9 | font-family: Roboto, 'Helvetica Neue', sans-serif;
10 | }
11 |
--------------------------------------------------------------------------------
/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import 'zone.js/testing';
4 | import { getTestBed } from '@angular/core/testing';
5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
6 |
7 | // First, initialize the Angular testing environment.
8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
9 | teardown: { destroyAfterEach: false },
10 | });
11 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./out-tsc/app",
5 | "types": []
6 | },
7 | "files": [
8 | "src/main.ts",
9 | "src/polyfills.ts"
10 | ],
11 | "include": [
12 | "src/**/*.d.ts"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "baseUrl": "./",
5 | "outDir": "./dist/out-tsc",
6 | "sourceMap": true,
7 | "declaration": false,
8 | "downlevelIteration": true,
9 | "experimentalDecorators": true,
10 | "module": "es2020",
11 | "moduleResolution": "node",
12 | "importHelpers": true,
13 | "target": "ES2022",
14 | "lib": ["es2018", "dom"],
15 | "paths": {
16 | "@ngneat/hotkeys": ["projects/ngneat/hotkeys/src/public-api.ts"]
17 | },
18 | "useDefineForClassFields": false
19 | },
20 | "angularCompilerOptions": {
21 | "fullTemplateTypeCheck": true,
22 | "strictInjectionParameters": true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./out-tsc/spec",
5 | "types": [
6 | "jasmine",
7 | "node"
8 | ]
9 | },
10 | "files": [
11 | "src/test.ts",
12 | "src/polyfills.ts"
13 | ],
14 | "include": [
15 | "src/**/*.spec.ts",
16 | "src/**/*.d.ts"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tslint:recommended",
3 | "rules": {
4 | "array-type": false,
5 | "arrow-parens": false,
6 | "deprecation": {
7 | "severity": "warning"
8 | },
9 | "component-class-suffix": true,
10 | "contextual-lifecycle": true,
11 | "directive-class-suffix": true,
12 | "directive-selector": [true, "attribute", "app", "camelCase"],
13 | "component-selector": [true, "element", "app", "kebab-case"],
14 | "import-blacklist": [true, "rxjs/Rx"],
15 | "interface-name": false,
16 | "max-classes-per-file": false,
17 | "max-line-length": [true, 140],
18 | "member-access": false,
19 | "member-ordering": [
20 | true,
21 | {
22 | "order": ["static-field", "instance-field", "static-method", "instance-method"]
23 | }
24 | ],
25 | "no-consecutive-blank-lines": false,
26 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"],
27 | "no-empty": false,
28 | "no-inferrable-types": [true, "ignore-params"],
29 | "no-non-null-assertion": true,
30 | "no-redundant-jsdoc": true,
31 | "no-switch-case-fall-through": true,
32 | "no-var-requires": false,
33 | "object-literal-key-quotes": [true, "as-needed"],
34 | "object-literal-sort-keys": false,
35 | "ordered-imports": false,
36 | "quotemark": [true, "single"],
37 | "trailing-comma": false,
38 | "no-conflicting-lifecycle": true,
39 | "no-host-metadata-property": true,
40 | "no-input-rename": true,
41 | "no-inputs-metadata-property": true,
42 | "no-output-native": true,
43 | "no-output-on-prefix": true,
44 | "no-output-rename": true,
45 | "no-outputs-metadata-property": true,
46 | "template-banana-in-box": true,
47 | "template-no-negated-async": true,
48 | "use-lifecycle-interface": true,
49 | "use-pipe-transform-interface": true
50 | }
51 | }
52 |
--------------------------------------------------------------------------------