Skip to content

Commit

Permalink
feat: Add keyboard shortcuts for search input (#979)
Browse files Browse the repository at this point in the history
* '/': Focus on search input
* 'Escape': Focus out of the search input and launch search
  • Loading branch information
reobin committed May 10, 2024
1 parent 9a1ce5c commit 34e17f8
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 1 deletion.
38 changes: 38 additions & 0 deletions src/__tests__/hooks/useEventListener.test.ts
@@ -0,0 +1,38 @@
import { fireEvent, renderHook } from '@testing-library/react';
import { vitest, describe, it, expect } from 'vitest';

import useEventListener from '@/hooks/useEventListener';

describe('useEvent', () => {
it('should fire a function when the event is triggered', () => {
const callback = vitest.fn();

renderHook(() => useEventListener('keydown', () => callback()));

fireEvent.keyDown(document, { key: 'k' });

expect(callback).toHaveBeenCalledTimes(1);
});

it('should add event listener to document', () => {
Document.prototype.addEventListener = vitest.fn();

renderHook(() => useEventListener('keydown', () => 'test'));

expect(Document.prototype.addEventListener).toHaveBeenCalledTimes(1);
});

it('should remove event listener when the hook is unmounted', () => {
Document.prototype.addEventListener = vitest.fn();
Document.prototype.removeEventListener = vitest.fn();

const { unmount } = renderHook(() =>
useEventListener('keydown', () => 'test'),
);

unmount();

expect(Document.prototype.addEventListener).toHaveBeenCalledTimes(1);
expect(Document.prototype.removeEventListener).toHaveBeenCalledTimes(1);
});
});
54 changes: 54 additions & 0 deletions src/__tests__/hooks/useKeyboardShortcut.test.ts
@@ -0,0 +1,54 @@
import { fireEvent, renderHook } from '@testing-library/react';
import { vitest, describe, it, expect } from 'vitest';

import useKeyboardShortcut from '@/hooks/useKeyboardShortcut';

describe('useKeyboardShortcut', () => {
it('should fire a function when keydown is detected', () => {
const shortcut = { k: vitest.fn() };

renderHook(() => useKeyboardShortcut(shortcut));

fireEvent.keyDown(document, { key: 'k' });

expect(shortcut.k).toHaveBeenCalledTimes(1);
});

it('should add event listener to document', () => {
Document.prototype.addEventListener = vitest.fn();

const shortcut = { k: () => null };

renderHook(() => useKeyboardShortcut(shortcut));

expect(Document.prototype.addEventListener).toHaveBeenCalledTimes(1);
});

it('should not fire a function when input is focused', () => {
const shortcut = { k: vitest.fn() };

const eventTarget = document.createElement('textarea');

renderHook(() => useKeyboardShortcut(shortcut));

eventTarget.focus();

fireEvent.keyDown(eventTarget, { code: 'KeyK' });

expect(shortcut.k).not.toHaveBeenCalled();
});

it('should remove event listener when the hook is unmounted', () => {
Document.prototype.addEventListener = vitest.fn();
Document.prototype.removeEventListener = vitest.fn();

const shortcut = { k: () => null };

const { unmount } = renderHook(() => useKeyboardShortcut(shortcut));

unmount();

expect(Document.prototype.addEventListener).toHaveBeenCalledTimes(1);
expect(Document.prototype.removeEventListener).toHaveBeenCalledTimes(1);
});
});
20 changes: 19 additions & 1 deletion src/components/searchInput/index.tsx
Expand Up @@ -2,11 +2,13 @@

import cn from 'classnames';
import { usePathname, useRouter } from 'next/navigation';
import { FormEvent, useState } from 'react';
import { FormEvent, useRef, useState } from 'react';

import FilterHelper from '@/helpers/filter';
import PageContextHelper from '@/helpers/pageContext';

import useKeyboardShortcut from '@/hooks/useKeyboardShortcut';

import IconEnter from '@/components/ui/icons/enter';
import IconForwardSlash from '@/components/ui/icons/forwardSlash';

Expand All @@ -17,8 +19,17 @@ export default function SearchInput() {
const pathname = usePathname();
const pageContext = PageContextHelper.get(pathname.split('/').slice(2));

const input = useRef<HTMLInputElement>(null);
const [value, setValue] = useState<string>(pageContext.filter.search || '');

useKeyboardShortcut({
'/': event => {
event.preventDefault();
input.current?.focus();
input.current?.select();
},
});

function onSubmit(event: FormEvent) {
event.preventDefault();
search(value);
Expand All @@ -43,6 +54,13 @@ export default function SearchInput() {
value={value}
onChange={event => setValue(event.target.value)}
className={styles.input}
ref={input}
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault();
search(value);
}
}}
/>
<IconEnter className={cn(styles.shortcut, styles.inFocus)} />
<IconForwardSlash className={cn(styles.shortcut, styles.outOfFocus)} />
Expand Down
21 changes: 21 additions & 0 deletions src/helpers/dom.ts
@@ -0,0 +1,21 @@
/**
* Returns true if the HTML element is an input.
* @param element The HTML element to verify
* @returns True if the element is an input, false otherwise
*/
function isInput(element: EventTarget | null): boolean {
if (element == null || !(element instanceof HTMLElement)) {
return false;
}

const { tagName, type } = element as HTMLInputElement;

return (
(tagName === 'INPUT' &&
['submit', 'reset', 'checkbox', 'radio'].indexOf(type) < 0) ||
tagName === 'TEXTAREA'
);
}

const DOMHelper = { isInput };
export default DOMHelper;
33 changes: 33 additions & 0 deletions src/hooks/useEventListener.ts
@@ -0,0 +1,33 @@
import { useEffect } from 'react';

/**
* Hook to add any event listener to the document.
*
* @example
* useEventListener("mousemove", () => console.log("mouse has moved"));
*
* @param event The name of the event to listen to
* @param callback Callback function when event has happened
*/
function useEventListener<EventType extends Event>(
event: string,
callback: (event: EventType) => void,
) {
useEffect(() => {
if (
!event ||
!callback ||
typeof window === 'undefined' ||
typeof document === 'undefined'
) {
return;
}

const eventListener = (event: Event) => callback(event as EventType);

document.addEventListener(event, eventListener);
return () => document.removeEventListener(event, eventListener);
}, [event, callback]);
}

export default useEventListener;
30 changes: 30 additions & 0 deletions src/hooks/useKeyboardShortcut.ts
@@ -0,0 +1,30 @@
import DOMHelper from '@/helpers/dom';

import useEvent from '@/hooks/useEventListener';

type KeyboardShortcuts = { [key: string]: (event: KeyboardEvent) => void };

/**
* Hook to configure keyboard shortcuts.
*
* @example
* useKeyboardShortcut({ b: () => toggleBackground(), c: () => toggleColor() });
*
* @param shortcuts The object configuring various shortcuts
*/
function useKeyboardShortcut(shortcuts: KeyboardShortcuts) {
useEvent<KeyboardEvent>('keydown', event => {
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
DOMHelper.isInput(event.target)
) {
return;
}

shortcuts?.[event.key]?.(event);
});
}

export default useKeyboardShortcut;

0 comments on commit 34e17f8

Please sign in to comment.