Skip to content

Commit

Permalink
Add Multiple Targets Support (#5)
Browse files Browse the repository at this point in the history
* Add multiple support

* Bump

* Implement multiple targets support

* Tweak bump

* Remove config nesting

* Tweak bump

* Fix tests

* Remove jsdoc

* Add examples

* Tweak

* Add config object
  • Loading branch information
PauloMFJ authored Jan 20, 2023
1 parent 6e667d4 commit 1d9e47a
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 23 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ React hook for listening to custom `keydown` [events](https://developer.mozilla.

## Introduction

This hook optimizes keyboard event handling by utilizing a single window `keydown` event listener for all instances of the hook, resulting in a more streamlined and efficient process.
This hook optimizes keyboard event handling by only initializing a single event listener for each target used, resulting in a more streamlined and efficient process.

This library is also SSR safe, and only runs on the client.

Expand Down Expand Up @@ -50,6 +50,17 @@ useKeydown(["KeyA", "KeyG"], () => {}); // Do something on "A" or "G"...

**Note:** When using multiple keys, the callback will be called if any of the defined keys are pressed.

## Using Custom Targets

By default, the hook will listen to the `window` object. You can however listen to any custom target by passing it as `target` within the _optional_ config object. This accepts any object that extends `EventTarget`, such as; `document` or `HTMLElement`. For example:

```tsx
import useKeydown from "@buildinams/use-keydown";

const elementRef = useRef<HTMLDivElement>(null);
useKeydown("Enter", () => {}, { target: elementRef }); // Do something on "Enter"...
```

## Requirements

This library requires a minimum React version of `18.0.0`.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export type { OnChangeEvent } from "./types";
export type { Config, EventTargetRef, OnChangeEvent, Target } from "./types";
export { default } from "./useKeydown";
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ export interface Listener {
targetKeyCode: string | string[];
onChange: OnChangeEvent;
}

export interface EventTargetRef {
current: EventTarget | null;
}

export type Target = EventTarget | EventTargetRef;

export interface Config {
/**
* A specify dom node or ref you want to attach the event listener to.
* Defaults to `window`.
*/
target?: Target;
}
82 changes: 65 additions & 17 deletions src/useKeydown.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
import { useEffect } from "react";

import { Listener, OnChangeEvent } from "./types";
import { Config, Listener, OnChangeEvent } from "./types";
import { getEventTargetFromConfig } from "./utils";

const listeners = new Set<Listener>();
const targets = new Map<EventTarget, Set<Listener>>();

let isHooked = false;
const addListener = (
eventTarget: EventTarget,
listener: Listener,
handleKeydown: (baseEvent: Event) => void
) => {
const listeners = targets.get(eventTarget);

// If target already exists, add this listener to existing set of listeners
if (listeners) listeners.add(listener);
// Else, target is new, so create new event listener
else {
targets.set(eventTarget, new Set([listener]));
eventTarget.addEventListener("keydown", handleKeydown);
}
};

const removeListener = (
eventTarget: EventTarget,
listener: Listener,
handleKeydown: (baseEvent: Event) => void
) => {
const listeners = targets.get(eventTarget);
if (!listeners) return;

// Remove this listener from existing set of listeners
listeners.delete(listener);

// If there are no more listeners, remove the event listener
if (listeners.size === 0) {
targets.delete(eventTarget);
eventTarget.removeEventListener("keydown", handleKeydown);
}
};

const handleTargetKeydown = (
eventTarget: EventTarget,
event: KeyboardEvent
) => {
const listeners = targets.get(eventTarget);
if (!listeners) return;

const handleKeydown = (event: KeyboardEvent) => {
listeners.forEach((listener) => {
const { targetKeyCode, onChange } = listener;

Expand All @@ -27,36 +66,45 @@ const handleKeydown = (event: KeyboardEvent) => {
*
* @param onChange - The callback to invoke when window `keydown` event is fired and the target key is pressed.
*
* @param config - Optional configuration object.
* @param config.target - Lets you specify a dom node or ref you want to attach the event listener to. Defaults to `window`.
*
* @example
* useKeydown("KeyA", (event) => console.log(event));
*
* @example
* useKeydown("KeyG", (event) => {
* if (event.ctrlKey) console.log("Ctrl + G Pressed!");
* });
*
* @example
* useKeydown(["Escape", "Escape"], () => console.log("Escape + Escape Pressed!"));
*
* @example
* useKeydown("Escape", () => {}, { target: document });
*/
const useKeydown = (
targetKeyCode: string | string[],
onChange: OnChangeEvent
onChange: OnChangeEvent,
config?: Config
) => {
useEffect(() => {
const eventTarget = getEventTargetFromConfig(config);
const listener: Listener = { targetKeyCode, onChange };
listeners.add(listener);

if (!isHooked) {
window.addEventListener("keydown", handleKeydown);
isHooked = true;
}
const handleKeydown = (baseEvent: Event) => {
// As user can pass in a custom 'target', we need to check if the event is
// a KeyboardEvent before we can safely access the 'event.code' property
const event = baseEvent as KeyboardEvent;
if (event.code) handleTargetKeydown(eventTarget, event);
};

return () => {
listeners.delete(listener);
addListener(eventTarget, listener, handleKeydown);

if (listeners.size === 0) {
window.removeEventListener("keydown", handleKeydown);
isHooked = false;
}
return () => {
removeListener(eventTarget, listener, handleKeydown);
};
}, [onChange, targetKeyCode]);
}, [onChange, config, targetKeyCode]);
};

export default useKeydown;
19 changes: 19 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Config } from "./types";

export const getEventTargetFromConfig = (config?: Config) => {
if (config?.target) {
const { target } = config;

// If the target is a ref...
if ("current" in target) {
// ...and the ref has a value, return it
if (target.current) return target.current;
}

// Otherwise, return the target
else return target;
}

// If no target is specified, or it's null, return window
return window;
};
107 changes: 103 additions & 4 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";

import useKeydown, { OnChangeEvent } from "../dist";
import useKeydown, { OnChangeEvent, Target } from "../dist";

interface MockComponentProps {
targetKeyCode: string | string[];
onChange: OnChangeEvent;
target?: Target;
}

const MockComponent = ({ targetKeyCode, onChange }: MockComponentProps) => {
useKeydown(targetKeyCode, onChange);
const MockComponent = ({
targetKeyCode,
onChange,
target,
}: MockComponentProps) => {
useKeydown(targetKeyCode, onChange, { target });
return <div />;
};

Expand All @@ -19,7 +24,7 @@ describe("The hook", () => {
jest.spyOn(window, "removeEventListener");
});

it("adds event listeners on mount", () => {
it("adds event listener on mount", () => {
render(<MockComponent targetKeyCode="KeyG" onChange={jest.fn()} />);

expect(window.addEventListener).toHaveBeenCalledWith(
Expand All @@ -32,16 +37,107 @@ describe("The hook", () => {
const { unmount } = render(
<MockComponent targetKeyCode="KeyG" onChange={jest.fn()} />
);

unmount();

expect(window.removeEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function)
);
});

it("adds event listeners on mount with different targets", () => {
jest.spyOn(document, "addEventListener");

render(
<>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={window}
/>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={document}
/>
</>
);

expect(window.addEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function)
);
expect(document.addEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function)
);
});

it("removes event listener on unmount with different targets", () => {
jest.spyOn(document, "removeEventListener");

const { unmount } = render(
<>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={window}
/>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={document}
/>
</>
);

unmount();

expect(window.removeEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function)
);
expect(document.removeEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function)
);
});

it("only adds event listeners once if called multiple times with same target", () => {
let documentKeydownCalled = 0;
jest.spyOn(document, "addEventListener").mockImplementation(
jest.fn((event) => {
if (event === "keydown") documentKeydownCalled++;
})
);

render(
<>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={window}
/>
<MockComponent
targetKeyCode="KeyA"
onChange={jest.fn()}
target={document}
/>
<MockComponent
targetKeyCode="KeyG"
onChange={jest.fn()}
target={document}
/>
</>
);

expect(documentKeydownCalled).toBe(1);
});

it("calls 'onChange' with the correct arguments on keydown", () => {
const onChange = jest.fn();

render(<MockComponent targetKeyCode="KeyG" onChange={onChange} />);

fireEvent.keyDown(window, { code: "KeyG" });
Expand All @@ -53,6 +149,7 @@ describe("The hook", () => {

it("does not call 'onChange' if a different key is pressed", () => {
const onChange = jest.fn();

render(<MockComponent targetKeyCode="KeyG" onChange={onChange} />);

fireEvent.keyDown(window, { code: "Escape" });
Expand All @@ -62,6 +159,7 @@ describe("The hook", () => {

it("can be used with key modifiers", () => {
const onChange = jest.fn();

render(<MockComponent targetKeyCode="KeyG" onChange={onChange} />);

fireEvent.keyDown(window, { code: "KeyG", ctrlKey: true });
Expand Down Expand Up @@ -113,6 +211,7 @@ describe("The hook", () => {

it("calls 'onChange' if any key matches in a list of keys", () => {
const onChange = jest.fn();

render(
<MockComponent targetKeyCode={["KeyA", "KeyG"]} onChange={onChange} />
);
Expand Down

0 comments on commit 1d9e47a

Please sign in to comment.