Skip to content

Commit 5e2a3ab

Browse files
authored
Migration and Refactors (#107)
* refactor: modernise build tooling, improve types, and add edge-case tests - Make onClickAway memoisation-safe using a ref pattern - Wrap handleBubbledEvents in useCallback for stable child props - Remove HTMLAttributes extension from Props interface - Tighten onClickAway event type to FocusEvent | MouseEvent | TouchEvent - Add package.json exports field for modern ESM/CJS resolution - Add engines field (node >= 18) and fix prepublishOnly lifecycle hook - Migrate from Rollup 2 to Rollup 4 with official plugins - Replace ghooks with husky + lint-staged - Remove unmaintained dependencies (rimraf, npm-run-all, coveralls) - Update documentation links from reactjs.org to react.dev - Add React 19 deprecation note for React.Children.only - Add tests for callback identity change, nested listeners, and unmount safety * ci: bump Node to 22 and actions to v4 for semantic-release@25 compat * ci: consolidate publish workflows into one * feat: rename bodyEventsToCapture to extraEvents * ci: add missing OIDC permissions for semantic-release npm publishing * ci: fetch all history for semantic-release * fix: update package lock * chore: add .npmignore to exclude .github folder from npm package * fix: regenerate package-lock.json to include optional rollup native dependencies
1 parent ea1c8b3 commit 5e2a3ab

15 files changed

Lines changed: 9818 additions & 6530 deletions

.github/workflows/build-test-pr.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ jobs:
1717
test_pull_request:
1818
runs-on: ubuntu-latest
1919
steps:
20-
- uses: actions/checkout@v3
21-
- uses: actions/setup-node@v3
20+
- uses: actions/checkout@v4
21+
- uses: actions/setup-node@v4
2222
with:
2323
node-version-file: '.nvmrc'
2424
cache: 'npm'

.github/workflows/manual-publish-on-npm.yml

Lines changed: 0 additions & 18 deletions
This file was deleted.

.github/workflows/publish-on-npm.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ on:
66
paths:
77
- 'src/**'
88
- package-lock.json
9+
workflow_dispatch:
910

1011
jobs:
1112
release:
1213
runs-on: ubuntu-latest
14+
permissions:
15+
contents: write
16+
id-token: write
1317
steps:
14-
- uses: actions/checkout@v3
15-
- uses: actions/setup-node@v3
18+
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
21+
- uses: actions/setup-node@v4
1622
with:
1723
node-version-file: '.nvmrc'
1824
cache: 'npm'

.github/workflows/report-coverage.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ jobs:
1111
report_test_coverage:
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v3
15-
- uses: actions/setup-node@v3
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-node@v4
1616
with:
1717
node-version-file: '.nvmrc'
1818
cache: 'npm'

.github/workflows/test-main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ jobs:
88
test_push:
99
runs-on: ubuntu-latest
1010
steps:
11-
- uses: actions/checkout@v3
12-
- uses: actions/setup-node@v3
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-node@v4
1313
with:
1414
node-version-file: '.nvmrc'
1515
cache: 'npm'

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.github/

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20.11.0
1+
22

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ yarn add react-click-away-listener
2727

2828
- It's quite **small** in size! Just [![Bundlephobia](https://img.shields.io/bundlephobia/min/react-click-away-listener.svg?style=flat-square&label 'Bundle size (minified)')](https://bundlephobia.com/result?p=react-click-away-listener) minified, or [![Bundlephobia](https://img.shields.io/bundlephobia/minzip/react-click-away-listener.svg?style=flat-square&label 'Bundle size (minified+gzipped)')](https://bundlephobia.com/result?p=react-click-away-listener) minified & gzipp’d.
2929
- It's built with **TypeScript**.
30-
- It works with [React Portals](https://reactjs.org/docs/portals.html) ([v2.0.0](https://github.com/ooade/react-click-away-listener/releases/tag/v2.0.0) onwards).
30+
- It works with [React Portals](https://react.dev/reference/react-dom/createPortal) ([v2.0.0](https://github.com/ooade/react-click-away-listener/releases/tag/v2.0.0) onwards).
3131
- It supports **mouse**, **touch** and **focus** events.
3232

3333
## Usage
@@ -55,7 +55,9 @@ const App = () => {
5555

5656
### Caveats
5757

58-
[v2.0.0](https://github.com/ooade/react-click-away-listener/releases/tag/v2.0.0) has breaking changes which uses the [`React.Children.only`](https://reactjs.org/docs/react-api.html#reactchildrenonly) API.
58+
[v2.0.0](https://github.com/ooade/react-click-away-listener/releases/tag/v2.0.0) has breaking changes which uses the [`React.Children.only`](https://react.dev/reference/react/Children#children-only) API.
59+
60+
> **Note:** `React.Children.only` is considered a legacy API as of React 19. See the [React docs](https://react.dev/reference/react/Children#alternatives) for details.
5961
6062
Thus, the following caveats apply for the `children` of the `<ClickAwayListener>` component:
6163

@@ -70,7 +72,7 @@ Error: Element type is invalid: expected a string (for built-in components) or a
7072
Check the render method of `ClickAwayListener`.
7173
```
7274

73-
If you have multiple child components to pass, you can simply wrap them around a [React Fragment](https://reactjs.org/docs/fragments.html) like so:
75+
If you have multiple child components to pass, you can simply wrap them around a [React Fragment](https://react.dev/reference/react/Fragment) like so:
7476

7577
```jsx
7678
// Assume the `handleClickAway` function is defined.
@@ -83,7 +85,7 @@ If you have multiple child components to pass, you can simply wrap them around a
8385
</ClickAwayListener>
8486
```
8587

86-
Or if you only have text nodes, you can also wrap them in a [React Fragment](https://reactjs.org/docs/fragments.html) like so:
88+
Or if you only have text nodes, you can also wrap them in a [React Fragment](https://react.dev/reference/react/Fragment) like so:
8789

8890
```jsx
8991
// Assume the `handleClickAway` function is defined.
@@ -100,10 +102,11 @@ Or if you only have text nodes, you can also wrap them in a [React Fragment](htt
100102
| mouseEvent | 'click' \|<br/>'mousedown' \|<br/>'mouseup' | 'click' | The mouse event type that gets fired on ClickAway |
101103
| touchEvent | 'touchstart' \|<br/>'touchend' | 'touchend' | The touch event type that gets fired on ClickAway |
102104
| focusEvent | 'focusin' \|<br/>'focusout' | 'focusin' | The focus event type that gets fired on ClickAway |
105+
| extraEvents | Array&lt;keyof DocumentEventMap&gt; | | Extra events to listen for (e.g. `keydown`) |
103106

104107
## Examples
105108

106-
- [A simple menu built with React Hooks](https://codesandbox.io/s/52384lyo8p)
109+
- [A simple menu built with React Hooks](https://codesandbox.io/s/52384lyo8p) _(Note: this example may use an older version of React)_
107110

108111
## LICENSE
109112

__tests__/index.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,33 @@ describe('ClickAway Listener', () => {
237237
expect(handleClick).toHaveBeenCalledTimes(1);
238238
});
239239

240+
it('should work with other body events', () => {
241+
const handleClick = jest.fn();
242+
const handleClickAway = jest.fn();
243+
244+
const { getByTestId } = render(
245+
<React.Fragment>
246+
<ClickAwayListener
247+
onClickAway={(event) => {
248+
if ((event as unknown as KeyboardEvent).key === 'Escape') {
249+
handleClickAway(event);
250+
}
251+
}}
252+
extraEvents={['keydown']}
253+
>
254+
<button onClick={handleClick}>Hello World</button>
255+
</ClickAwayListener>
256+
<input type="text" data-testid="input" />
257+
</React.Fragment>
258+
);
259+
jest.runOnlyPendingTimers();
260+
261+
fireEvent.keyDown(getByTestId('input'), { key: 'Enter' });
262+
expect(handleClickAway).toHaveBeenCalledTimes(0);
263+
fireEvent.keyDown(getByTestId('input'), { key: 'Escape' });
264+
expect(handleClickAway).toHaveBeenCalledTimes(1);
265+
});
266+
240267
it('should work with function refs', () => {
241268
const handleClickAway = jest.fn();
242269
let buttonRef;
@@ -312,4 +339,102 @@ describe('ClickAway Listener', () => {
312339
fireEvent.click(getByText(/A button/i));
313340
expect(handleClickAway).toHaveBeenCalledTimes(1);
314341
});
342+
343+
it('should fire the latest onClickAway when the callback changes mid-lifecycle', () => {
344+
const firstHandler = jest.fn();
345+
const secondHandler = jest.fn();
346+
347+
const App = () => {
348+
const [handler, setHandler] = React.useState(() => firstHandler);
349+
350+
return (
351+
<React.Fragment>
352+
<ClickAwayListener onClickAway={handler}>
353+
<div>Inside</div>
354+
</ClickAwayListener>
355+
<button onClick={() => setHandler(() => secondHandler)}>
356+
Change handler
357+
</button>
358+
<p>Outside text</p>
359+
</React.Fragment>
360+
);
361+
};
362+
363+
const { getByText } = render(<App />);
364+
jest.runOnlyPendingTimers();
365+
366+
fireEvent.click(getByText(/Outside text/i));
367+
expect(firstHandler).toHaveBeenCalledTimes(1);
368+
expect(secondHandler).toHaveBeenCalledTimes(0);
369+
370+
fireEvent.click(getByText(/Change handler/i));
371+
fireEvent.click(getByText(/Outside text/i));
372+
// firstHandler was called twice total: once for "Outside text" and once for "Change handler" (also outside)
373+
expect(firstHandler).toHaveBeenCalledTimes(2);
374+
expect(secondHandler).toHaveBeenCalledTimes(1);
375+
});
376+
377+
it('should only fire the correct handler with nested ClickAwayListeners', () => {
378+
const outerHandler = jest.fn();
379+
const innerHandler = jest.fn();
380+
381+
const { getByText } = render(
382+
<React.Fragment>
383+
<ClickAwayListener onClickAway={outerHandler}>
384+
<div>
385+
Outer
386+
<ClickAwayListener onClickAway={innerHandler}>
387+
<div>Inner</div>
388+
</ClickAwayListener>
389+
</div>
390+
</ClickAwayListener>
391+
<button>Fully outside</button>
392+
</React.Fragment>
393+
);
394+
jest.runOnlyPendingTimers();
395+
396+
// Clicking inside inner should not fire inner's handler
397+
fireEvent.click(getByText(/Inner/i));
398+
expect(innerHandler).toHaveBeenCalledTimes(0);
399+
expect(outerHandler).toHaveBeenCalledTimes(0);
400+
401+
// Clicking fully outside should fire both handlers
402+
fireEvent.click(getByText(/Fully outside/i));
403+
expect(outerHandler).toHaveBeenCalledTimes(1);
404+
expect(innerHandler).toHaveBeenCalledTimes(1);
405+
});
406+
407+
it('should not throw when the component unmounts during an event', () => {
408+
const handleClickAway = jest.fn();
409+
410+
const App = () => {
411+
const [show, setShow] = React.useState(true);
412+
413+
return (
414+
<React.Fragment>
415+
{show && (
416+
<ClickAwayListener onClickAway={handleClickAway}>
417+
<div>Inside</div>
418+
</ClickAwayListener>
419+
)}
420+
<button onClick={() => setShow(false)}>Unmount</button>
421+
</React.Fragment>
422+
);
423+
};
424+
425+
const { getByText } = render(<App />);
426+
jest.runOnlyPendingTimers();
427+
428+
// Unmount the ClickAwayListener
429+
fireEvent.click(getByText(/Unmount/i));
430+
431+
// Clicking after unmount should not throw
432+
expect(() => {
433+
fireEvent.click(document.body);
434+
}).not.toThrow();
435+
436+
// The handler should not have been called after unmount
437+
// It was called once when pressing "Unmount" (which is outside)
438+
expect(handleClickAway).toHaveBeenCalledTimes(1);
439+
});
315440
});

0 commit comments

Comments
 (0)