Skip to content

Commit c6c61b0

Browse files
Port use-listener package to aacc (#53)
1 parent a011584 commit c6c61b0

13 files changed

+475
-2
lines changed

.changeset/smart-suns-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'use-listener': minor
3+
---
4+
5+
Port to aacc

hooks/use-listener/.babelrc.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"presets": [
3+
[
4+
"@aacc/babel-preset",
5+
{
6+
"typescript": true,
7+
"react": true
8+
}
9+
]
10+
]
11+
}

hooks/use-listener/.eslintrc.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @type {import('eslint').Linter.Config}
3+
*/
4+
module.exports = {
5+
root: true,
6+
extends: ['@aacc/eslint-config/typescript'],
7+
parserOptions: {
8+
tsconfigRootDir: __dirname,
9+
project: 'tsconfig.eslint.json',
10+
},
11+
ignorePatterns: ['node_modules', 'dist'],
12+
}

hooks/use-listener/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# use-listener
2+
3+
The **most** type safe React hook for adding and removing event listeners.
4+
5+
Supports the following event targets:
6+
7+
- `Window`
8+
- `Document`
9+
- `HTMLElement`
10+
- `React.RefObject<HTMLElement>`
11+
12+
## Installation
13+
14+
```sh
15+
npm install use-listener
16+
```
17+
18+
## Usage
19+
20+
### With default target (`Window`)
21+
22+
```tsx
23+
import { useListener } from 'use-listener'
24+
25+
function App() {
26+
useListener('click', (e) => console.log(e))
27+
28+
return <div>Use listener</div>
29+
}
30+
```
31+
32+
> The `click` event and default `Window` target are used to resolve the event
33+
> object type (`MouseEvent`).
34+
35+
### With explicit target (`Document`)
36+
37+
```tsx
38+
import { useListener } from 'use-listener'
39+
40+
function App() {
41+
useListener('copy', (e) => console.log(e), window.document)
42+
43+
return <div>Use listener</div>
44+
}
45+
```
46+
47+
> Note: The `copy` event and explicit `Document` target are used to resolve the
48+
> event object type (`ClipboardEvent`).
49+
50+
### With React `ref` (`HTMLDivElement`)
51+
52+
```tsx
53+
import { useListener } from 'use-listener'
54+
55+
function App() {
56+
const ref = React.useRef<HTMLDivElement>(null)
57+
58+
useListener('mouseover', (e) => console.log(e), ref)
59+
60+
return <div ref={ref}>Use listener</div>
61+
}
62+
```
63+
64+
> Note: The `mouseover` event and inferred `HTMLDivElement` target are used to
65+
> resolve the event object type (`MouseEvent`)
66+
67+
### Dynamic React `ref` (`HTMLParagraphElement`)
68+
69+
```jsx
70+
import React from 'react'
71+
import { useListener } from 'use-listener'
72+
73+
function App() {
74+
const [element, ref] = useElementRef<HTMLParagraphElement>()
75+
const [on, setOn] = React.useState(false)
76+
77+
useListener('mouseover', (e) => console.log(e), element)
78+
79+
return (
80+
<div>
81+
{on && <p ref={ref}>Hover me</p>}
82+
<button onClick={() => setOn((prevOn) => !prevOn)}>
83+
Toggle element
84+
</button>
85+
</div>
86+
)
87+
}
88+
89+
function useElementRef<T extends HTMLElement>() {
90+
const [node, setNode] = React.useState<T | null>(null)
91+
92+
const ref: React.RefCallback<T> = React.useCallback((node) => {
93+
if (node !== null) setNode(node)
94+
}, [])
95+
96+
return [node, ref] as const
97+
}
98+
```
99+
100+
> Note: The `mouseover` event and dynamically set `HTMLParagraphElement` target
101+
> are used to resolve the event object type (`MouseEvent`)

hooks/use-listener/package.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "use-listener",
3+
"version": "0.1.4",
4+
"description": "",
5+
"author": "Aaron Casanova <[email protected]>",
6+
"license": "MIT",
7+
"main": "dist/cjs/index.js",
8+
"module": "dist/esm/index.mjs",
9+
"browser": "dist/browser/index.js",
10+
"types": "dist/types/index.d.ts",
11+
"exports": {
12+
".": {
13+
"types": "./dist/types/index.d.ts",
14+
"import": "./dist/esm/index.mjs",
15+
"require": "./dist/cjs/index.js"
16+
}
17+
},
18+
"scripts": {
19+
"dev": "npm-run-all --parallel 'build:* -- --watch'",
20+
"build": "npm-run-all --parallel build:*",
21+
"build:js": "rollup -c",
22+
"build:types": "tsc --emitDeclarationOnly",
23+
"type-check": "tsc --noEmit",
24+
"type-check:watch": "npm run type-check -- --watch",
25+
"lint": "TIMING=1 eslint . --ext .js,.ts --cache",
26+
"prepublishOnly": "npm run build"
27+
},
28+
"files": [
29+
"dist"
30+
],
31+
"peerDependencies": {
32+
"react": "^17.0.0"
33+
},
34+
"dependencies": {},
35+
"devDependencies": {
36+
"@aacc/babel-preset": "*",
37+
"@aacc/browserslist-config": "*",
38+
"@aacc/eslint-config": "*",
39+
"@aacc/tsconfigs": "*",
40+
"@rollup/plugin-babel": "^5.3.1",
41+
"@rollup/plugin-commonjs": "^21.1.0",
42+
"@rollup/plugin-node-resolve": "^13.2.1",
43+
"@types/react": "^18.0.21",
44+
"react": "^17.0.0",
45+
"rollup": "^2.70.2",
46+
"typescript": "^4.7.3"
47+
},
48+
"browserslist": [
49+
"extends @aacc/browserslist-config"
50+
],
51+
"publishConfig": {
52+
"access": "public",
53+
"@aacc:registry": "https://registry.npmjs.org"
54+
},
55+
"repository": {
56+
"type": "git",
57+
"url": "git+https://github.com/aaronccasanova/aacc.git",
58+
"directory": "hooks/use-listener"
59+
},
60+
"bugs": {
61+
"url": "https://github.com/aaronccasanova/aacc/issues"
62+
},
63+
"homepage": "https://github.com/aaronccasanova/aacc/blob/main/hooks/use-listener/README.md"
64+
}

hooks/use-listener/rollup.config.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import path from 'path'
2+
3+
import nodeResolve from '@rollup/plugin-node-resolve'
4+
import commonjs from '@rollup/plugin-commonjs'
5+
import babel from '@rollup/plugin-babel'
6+
7+
import pkg from './package.json'
8+
9+
const name = 'useListener'
10+
11+
const extensions = ['.js', '.jsx', '.ts', '.tsx']
12+
13+
/**
14+
* @type {import('rollup').RollupOptions}
15+
*/
16+
export default {
17+
input: 'src/index.ts',
18+
output: [
19+
{
20+
format: /** @type {const} */ ('cjs'),
21+
entryFileNames: '[name][assetExtname].js',
22+
dir: path.dirname(pkg.main),
23+
preserveModules: true,
24+
},
25+
{
26+
format: /** @type {const} */ ('es'),
27+
entryFileNames: '[name][assetExtname].mjs',
28+
dir: path.dirname(pkg.module),
29+
preserveModules: true,
30+
},
31+
{
32+
format: /** @type {const} */ ('iife'),
33+
file: pkg.browser,
34+
name,
35+
36+
// https://rollupjs.org/guide/en/#outputglobals
37+
// globals: {},
38+
},
39+
],
40+
plugins: [
41+
// Allows node_modules resolution
42+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
43+
nodeResolve({ extensions }),
44+
// Allow bundling cjs modules. Rollup doesn't understand cjs
45+
commonjs(),
46+
// Compile TypeScript/JavaScript files
47+
babel({
48+
extensions,
49+
babelHelpers: 'bundled',
50+
include: ['src/**/*'],
51+
}),
52+
],
53+
external: [
54+
...Object.keys(pkg.dependencies ?? {}),
55+
...Object.keys(pkg.peerDependencies ?? {}),
56+
],
57+
}

hooks/use-listener/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './use-listener'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react'
2+
3+
function isServer() {
4+
const isDOM =
5+
typeof window !== 'undefined' &&
6+
window.document &&
7+
window.document.documentElement
8+
9+
return !isDOM
10+
}
11+
12+
export const useIsomorphicLayoutEffect = isServer()
13+
? React.useEffect
14+
: React.useLayoutEffect
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from 'react'
2+
3+
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
4+
5+
/**
6+
* Acceptable target elements for `useListener`.
7+
*/
8+
type UseListenerTarget =
9+
| Window
10+
| Document
11+
| HTMLElement
12+
| React.RefObject<HTMLElement>
13+
14+
/**
15+
* Extracts the target element from a React `RefObject` or returns the input element.
16+
*/
17+
type ExtractTargetElement<Target> = Target extends React.RefObject<
18+
infer Element
19+
>
20+
? Element
21+
: Target
22+
23+
/**
24+
* Extracts a (lib.dom.ts) EventMap for a given target element.
25+
*/
26+
type ExtractEventMap<Target> = ExtractTargetElement<Target> extends Window
27+
? WindowEventMap
28+
: ExtractTargetElement<Target> extends Document
29+
? DocumentEventMap
30+
: HTMLElementEventMap
31+
32+
/**
33+
* Extracts all event names for a given target element.
34+
*/
35+
type ExtractEventName<Target> = keyof ExtractEventMap<
36+
ExtractTargetElement<Target>
37+
>
38+
39+
/**
40+
* Extracts the `event` object for a given event type.
41+
*/
42+
type ExtractEvent<
43+
Target,
44+
EventName extends ExtractEventName<Target>,
45+
> = ExtractEventMap<ExtractTargetElement<Target>>[EventName]
46+
47+
/**
48+
* React hook encapsulating the boilerplate logic for adding and removing event listeners.
49+
*/
50+
export function useListener<
51+
TargetEventName extends ExtractEventName<Target>,
52+
TargetEvent extends ExtractEvent<Target, TargetEventName>,
53+
Target extends UseListenerTarget = Window,
54+
>(
55+
eventName: TargetEventName,
56+
handler: (event: TargetEvent) => void,
57+
target?: undefined | null | Target,
58+
options?: undefined | AddEventListenerOptions,
59+
): void {
60+
const handlerRef = React.useRef(handler)
61+
const optionsRef = React.useRef(options)
62+
63+
useIsomorphicLayoutEffect(() => {
64+
handlerRef.current = handler
65+
}, [handler])
66+
67+
useIsomorphicLayoutEffect(() => {
68+
optionsRef.current = options
69+
}, [options])
70+
71+
React.useEffect(() => {
72+
if (!(typeof eventName === 'string' && target !== null)) return
73+
74+
let targetElement: Exclude<UseListenerTarget, React.RefObject<HTMLElement>>
75+
76+
if (typeof target === 'undefined') {
77+
targetElement = window
78+
} else if ('current' in target) {
79+
if (target.current === null) return
80+
81+
targetElement = target.current
82+
} else {
83+
targetElement = target
84+
}
85+
86+
const eventOptions = optionsRef.current
87+
88+
const eventListener = (event: Event) =>
89+
handlerRef.current(event as unknown as TargetEvent)
90+
91+
targetElement.addEventListener(eventName, eventListener, eventOptions)
92+
93+
// eslint-disable-next-line consistent-return
94+
return () => {
95+
targetElement.removeEventListener(eventName, eventListener, eventOptions)
96+
}
97+
}, [eventName, target])
98+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
// https://typescript-eslint.io/docs/linting/monorepo#one-root-tsconfigjson
3+
"extends": "./tsconfig.json",
4+
"compilerOptions": {
5+
// Ensures this config is not used for a build
6+
"noEmit": true
7+
},
8+
"include": [
9+
// Paths to lint
10+
"src",
11+
".eslintrc.js",
12+
"rollup.config.js"
13+
]
14+
}

0 commit comments

Comments
 (0)