Skip to content

Commit 00a263f

Browse files
authored
Merge pull request #282 from theKashey/return-focus
feat: smart return focus feature
2 parents 617388a + 76ed218 commit 00a263f

8 files changed

+193
-28
lines changed

.size-limit.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[
22
{
33
"path": "dist/cjs/UI.js",
4-
"limit": "3.7 KB",
4+
"limit": "3.8 KB",
55
"ignore": [
66
"prop-types",
77
"@babel/runtime",
@@ -10,7 +10,7 @@
1010
},
1111
{
1212
"path": "dist/es2015/sidecar.js",
13-
"limit": "4.2 KB",
13+
"limit": "4.5 KB",
1414
"ignore": [
1515
"prop-types",
1616
"@babel/runtime",
@@ -19,7 +19,7 @@
1919
},
2020
{
2121
"path": "dist/es2015/index.js",
22-
"limit": "6.5 KB",
22+
"limit": "6.8 KB",
2323
"ignore": [
2424
"prop-types",
2525
"@babel/runtime",

README.md

+29-2
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ Demo - https://codesandbox.io/s/5wmrwlvxv4.
6161
FocusLock has few props to tune behavior, all props are optional:
6262
- `disabled`, to disable(enable) behavior without altering the tree.
6363
- `className`, to set the `className` of the internal wrapper.
64-
- `returnFocus`, to return focus into initial position on unmount(not disable).
65-
> By default `returnFocus` is disabled, so FocusLock will __not__ restore original focus on deactivation.
64+
- `returnFocus`, to return focus into initial position on unmount
65+
> By default `returnFocus` is disabled, so FocusLock __will not__ restore original focus on deactivation.
66+
> This was done mostly to avoid breaking changes. We __strong recommend enabling it__, to provide a better user experience.
6667
6768
This is expected behavior for Modals, but it is better to implement it by your self. See [unmounting and focus management](https://github.com/theKashey/react-focus-lock#unmounting-and-focus-management) for details
6869
- `persistentFocus=false`, requires any element to be focused. This also disables text selections inside, and __outside__ focus lock.
@@ -329,6 +330,32 @@ to allow user _tab_ into address bar.
329330
>
330331
```
331332

333+
## Return focus to another node
334+
In some cases the original node that was focused before the lock was activated is not the desired node to return focus to.
335+
Some times this node might not exists at all.
336+
337+
- first of all, FocusLock need a moment to record this node, please do not hide it onClick, but hide onBlur (Dropdown, looking at you)
338+
- second, you may specify a callback as `returnFocus`, letting you decide where to return focus to.
339+
```tsx
340+
<FocusLock
341+
returnFocus={(suggestedNode) => {
342+
// somehow activeElement should not be changed
343+
if(document.activeElement.hasAttributes('main-content')) {
344+
// opt out from default behavior
345+
return false;
346+
}
347+
if (someCondition(suggestedNode)) {
348+
// proceed with the suggested node
349+
return true;
350+
}
351+
// handle return focus manually
352+
document.getElementById('the-button').focus();
353+
// opt out from default behavior
354+
return false;
355+
}}
356+
/>
357+
````
358+
332359
## Return focus with no scroll
333360
> read more at the [issue #83](https://github.com/theKashey/react-focus-lock/issues/83) or
334361
[mdn article](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus).

_tests/FocusLock.spec.js

+75
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,81 @@ describe('react-focus-lock', () => {
244244
expect(document.activeElement.innerHTML).to.be.equal('d-action0');
245245
});
246246

247+
it('Should return focus to the possible place', async () => {
248+
const LockTest = ({ action }) => (
249+
<FocusLock returnFocus>
250+
<button id="focus-action" onClick={action}>
251+
inside
252+
</button>
253+
</FocusLock>
254+
);
255+
256+
const TriggerTest = () => {
257+
const [clicked, setClicked] = React.useState(false);
258+
const [removed, setRemoved] = React.useState(false);
259+
return (
260+
<>
261+
{removed ? null : <button id="trigger" onClick={() => setClicked(true)}>trigger</button>}
262+
<button id="follower">another action</button>
263+
{clicked && (
264+
<LockTest
265+
action={() => {
266+
setRemoved(true);
267+
setTimeout(() => {
268+
setClicked(false);
269+
}, 1);
270+
}}
271+
/>
272+
)}
273+
</>
274+
);
275+
};
276+
277+
const wrapper = mount(<TriggerTest />);
278+
279+
document.getElementById('trigger').focus();
280+
wrapper.find('#trigger').simulate('click');
281+
expect(document.activeElement.innerHTML).to.be.equal('inside');
282+
// await tick(1);
283+
wrapper.find('#focus-action').simulate('click');
284+
await tick(5);
285+
expect(document.activeElement.innerHTML).to.be.equal('another action');
286+
});
287+
288+
it.only('Should return focus to the possible place: timing', async () => {
289+
const LockTest = ({ action }) => (
290+
<FocusLock returnFocus>
291+
<button id="focus-action" onClick={action}>
292+
inside
293+
</button>
294+
</FocusLock>
295+
);
296+
297+
const TriggerTest = () => {
298+
const [clicked, setClicked] = React.useState(false);
299+
return (
300+
<>
301+
{clicked ? null : <button id="trigger" onClick={() => setClicked(true)}>trigger</button>}
302+
<button id="follower">another action</button>
303+
{clicked && (
304+
<LockTest
305+
action={() => { setClicked(false); }}
306+
/>
307+
)}
308+
</>
309+
);
310+
};
311+
312+
const wrapper = mount(<TriggerTest />);
313+
314+
wrapper.find('#trigger').simulate('click');
315+
await tick();
316+
expect(document.activeElement.innerHTML).to.be.equal('inside');
317+
wrapper.find('#focus-action').simulate('click');
318+
await tick();
319+
expect(document.activeElement).to.be.equal(document.body);
320+
});
321+
247322
it('Should focus on inputs', (done) => {
248323
const wrapper = mount(<div>
249324
<div>

_tests/restore-focus.sidecar.spec.js

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as React from 'react';
2+
import { expect } from 'chai';
3+
import { render, screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import { sidecar } from 'use-sidecar';
6+
import FocusLock from '../src/UI';
7+
8+
const tick = (tm = 1) => new Promise(resolve => setTimeout(resolve, tm));
9+
10+
const FocusLockSidecar = sidecar(
11+
() => import('../src/sidecar').then(async (x) => {
12+
await tick();
13+
return x;
14+
}),
15+
);
16+
17+
it('Should return focus to the possible place: timing', async () => {
18+
const LockTest = ({ action }) => (
19+
<FocusLock returnFocus sideCar={FocusLockSidecar}>
20+
<button data-testid="focus-action" onClick={action}>
21+
inside
22+
</button>
23+
</FocusLock>
24+
);
25+
26+
const TriggerTest = () => {
27+
const [clicked, setClicked] = React.useState(false);
28+
return (
29+
<>
30+
{clicked ? null : <button data-testid="trigger" onClick={() => setClicked(true)}>trigger</button>}
31+
<button id="follower">another action</button>
32+
{clicked && (
33+
<LockTest
34+
action={() => { setClicked(false); }}
35+
/>
36+
)}
37+
</>
38+
);
39+
};
40+
41+
render(<TriggerTest />);
42+
43+
screen.getByTestId('trigger').focus();
44+
await userEvent.click(screen.getByTestId('trigger'));
45+
await tick(5);
46+
expect(document.activeElement.innerHTML).to.be.equal('inside');
47+
await userEvent.click(screen.getByTestId('focus-action'));
48+
await tick();
49+
console.log('active is ', document.activeElement.tagName);
50+
expect(document.activeElement).to.be.equal(document.body);
51+
});

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@storybook/addon-links": "^5.1.8",
7272
"@storybook/react": "^5.1.8",
7373
"@testing-library/react": "^12.0.0",
74+
"@testing-library/user-event": "^12.0.0",
7475
"@types/react": "^18.0.8",
7576
"babel-eslint": "^10.0.1",
7677
"babel-loader": "^8.0.4",

src/Lock.js

+16-16
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
55
import * as constants from 'focus-lock/constants';
66
import { useMergeRefs } from 'use-callback-ref';
77

8-
import { useEffect } from 'react';
98
import { hiddenGuard } from './FocusGuard';
10-
import { mediumFocus, mediumBlur, mediumSidecar } from './medium';
9+
import {
10+
mediumFocus, mediumBlur, mediumSidecar,
11+
} from './medium';
1112
import { focusScope } from './scope';
1213

1314
const emptyArray = [];
@@ -48,10 +49,16 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) {
4849

4950
// SIDE EFFECT CALLBACKS
5051

51-
const onActivation = React.useCallback(() => {
52-
originalFocusedElement.current = (
53-
originalFocusedElement.current || (document && document.activeElement)
54-
);
52+
const onActivation = React.useCallback(({ captureFocusRestore }) => {
53+
if (!originalFocusedElement.current) {
54+
const activeElement = document?.activeElement;
55+
originalFocusedElement.current = activeElement;
56+
// store stack reference
57+
if (activeElement !== document.body) {
58+
originalFocusedElement.current = captureFocusRestore(activeElement);
59+
}
60+
}
61+
5562
if (observed.current && onActivationCallback) {
5663
onActivationCallback(observed.current);
5764
}
@@ -67,17 +74,10 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) {
6774
update();
6875
}, [onDeactivationCallback]);
6976

70-
useEffect(() => {
71-
if (!disabled) {
72-
// cleanup return focus on trap deactivation
73-
// sideEffect/returnFocus should happen by this time
74-
originalFocusedElement.current = null;
75-
}
76-
}, []);
77-
7877
const returnFocus = React.useCallback((allowDefer) => {
79-
const { current: returnFocusTo } = originalFocusedElement;
80-
if (returnFocusTo && returnFocusTo.focus) {
78+
const { current: focusRestore } = originalFocusedElement;
79+
if (focusRestore) {
80+
const returnFocusTo = (typeof focusRestore === 'function' ? focusRestore() : focusRestore) || document.body;
8181
const howToReturnFocus = typeof shouldReturnFocus === 'function' ? shouldReturnFocus(returnFocusTo) : shouldReturnFocus;
8282
if (howToReturnFocus) {
8383
const returnFocusOptions = typeof howToReturnFocus === 'object' ? howToReturnFocus : undefined;

src/Trap.js

+11-7
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
focusIsHidden, expandFocusableNodes,
88
focusNextElement,
99
focusPrevElement,
10+
captureFocusRestore,
1011
} from 'focus-lock';
1112
import { deferAction, extractRef } from './util';
1213
import { mediumFocus, mediumBlur, mediumEffect } from './medium';
@@ -211,6 +212,14 @@ function reducePropsToState(propsList) {
211212
.filter(({ disabled }) => !disabled);
212213
}
213214

215+
const focusLockAPI = {
216+
moveFocusInside,
217+
focusInside,
218+
focusNextElement,
219+
focusPrevElement,
220+
captureFocusRestore,
221+
};
222+
214223
function handleStateChangeOnClient(traps) {
215224
const trap = traps.slice(-1)[0];
216225
if (trap && !lastActiveTrap) {
@@ -234,7 +243,7 @@ function handleStateChangeOnClient(traps) {
234243
if (trap) {
235244
lastActiveFocus = null;
236245
if (!sameTrap || lastTrap.observed !== trap.observed) {
237-
trap.onActivation();
246+
trap.onActivation(focusLockAPI);
238247
}
239248
activateTrap(true);
240249
deferAction(activateTrap);
@@ -247,12 +256,7 @@ function handleStateChangeOnClient(traps) {
247256
// bind medium
248257
mediumFocus.assignSyncMedium(onFocus);
249258
mediumBlur.assignMedium(onBlur);
250-
mediumEffect.assignMedium(cb => cb({
251-
moveFocusInside,
252-
focusInside,
253-
focusNextElement,
254-
focusPrevElement,
255-
}));
259+
mediumEffect.assignMedium(cb => cb(focusLockAPI));
256260

257261
export default withSideEffect(
258262
reducePropsToState,

yarn.lock

+7
Original file line numberDiff line numberDiff line change
@@ -1856,6 +1856,13 @@
18561856
"@testing-library/dom" "^8.0.0"
18571857
"@types/react-dom" "<18.0.0"
18581858

1859+
"@testing-library/user-event@^12.0.0":
1860+
version "12.8.3"
1861+
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-12.8.3.tgz#1aa3ed4b9f79340a1e1836bc7f57c501e838704a"
1862+
integrity sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==
1863+
dependencies:
1864+
"@babel/runtime" "^7.12.5"
1865+
18591866
"@tootallnate/once@1":
18601867
version "1.1.2"
18611868
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"

0 commit comments

Comments
 (0)