Skip to content

Commit

Permalink
Merge pull request #324 from theKashey/keep-focus
Browse files Browse the repository at this point in the history
fix: auto-focus in case of activeElement disappearance, fixes #321
  • Loading branch information
theKashey authored Aug 25, 2024
2 parents 57999e2 + 72b4d4b commit 29d2c8b
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 13 deletions.
6 changes: 3 additions & 3 deletions .size-limit.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"path": "dist/cjs/UI.js",
"limit": "3.8 KB",
"limit": "3.9 KB",
"ignore": [
"prop-types",
"@babel/runtime",
Expand All @@ -10,7 +10,7 @@
},
{
"path": "dist/es2015/sidecar.js",
"limit": "4.6 KB",
"limit": "4.85 KB",
"ignore": [
"prop-types",
"@babel/runtime",
Expand All @@ -19,7 +19,7 @@
},
{
"path": "dist/es2015/index.js",
"limit": "6.9 KB",
"limit": "7.2 KB",
"ignore": [
"prop-types",
"@babel/runtime",
Expand Down
92 changes: 87 additions & 5 deletions _tests/keep-focus.spec.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,102 @@
import React from 'react';
import {
render, screen,
render,
} from '@testing-library/react';
import { expect } from 'chai';
import FocusLock from '../src';
import { deferAction } from '../src/util';

describe('Focus restoration', () => {
describe('Focus restoration', async () => {
it('maintains focus on element removal', async () => {
const { rerender } = render(
<FocusLock>
<button type="button" key={1}>btn1</button>
</FocusLock>,
);
//
expect(document.activeElement.innerHTML).to.be.equal('btn1');

rerender(
<FocusLock>
<button type="button" key={2}>new button</button>
</FocusLock>,
);

// wait
await new Promise(deferAction);

expect(document.activeElement.innerHTML).to.be.equal('new button');
});

it('handles disabled elements', async () => {
const { rerender } = render(
<FocusLock>
<button type="button">btn1</button>
<button type="button">btn2</button>
</FocusLock>,
);
//
expect(document.activeElement.innerHTML).to.be.equal('btn1');


// https://github.com/jsdom/jsdom/issues/3029 - jsdom does trigger blur on disabled
document.activeElement.blur();
document.body.focus();

rerender(
<FocusLock>
<button type="button" disabled>btn1</button>
<button type="button">btn2</button>
</FocusLock>,
);

// wait
await new Promise(deferAction);

expect(document.activeElement.innerHTML).to.be.equal('btn2');
});

it('moves focus to the nearest element', async () => {
render(
<FocusLock>
<button type="button">test</button>
<button type="button">btn1</button>
<button type="button">btn2</button>
<button type="button" id="middle-button">btn3</button>
<button type="button">btn4</button>
<button type="button">btn5</button>
</FocusLock>,
);
//
expect(document.activeElement).to.be.equal(screen.getByRole('button'));
const middleButton = document.getElementById('middle-button');
middleButton.focus();
expect(document.activeElement).to.be.equal(middleButton);

middleButton.parentElement.removeChild(middleButton);
// wait
await new Promise(deferAction);

// btn4 "replaces" bnt3 in visual order
expect(document.activeElement.innerHTML).to.be.equal('btn4');
});

it.todo('selects closes element to restore focus');
it('moves focus to the nearest element before', async () => {
render(
<FocusLock>
<button type="button">btn1</button>
<button type="button">btn2</button>
<button type="button" id="middle-button">btn3</button>
</FocusLock>,
);
//
const middleButton = document.getElementById('middle-button');
middleButton.focus();
expect(document.activeElement).to.be.equal(middleButton);

middleButton.parentElement.removeChild(middleButton);
// wait
await new Promise(deferAction);

// btn2 is just before bnt3
expect(document.activeElement.innerHTML).to.be.equal('btn2');
});
});
1 change: 1 addition & 0 deletions src/Lock.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) {
onDeactivation={onDeactivation}
returnFocus={returnFocus}
focusOptions={focusOptions}
noFocusGuards={noFocusGuards}
/>
)}
<Container
Expand Down
57 changes: 53 additions & 4 deletions src/Trap.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import PropTypes from 'prop-types';
import withSideEffect from 'react-clientside-effect';
import {
moveFocusInside, focusInside,
focusIsHidden, expandFocusableNodes,
focusIsHidden,
expandFocusableNodes,
getFocusableNodes,
focusNextElement,
focusPrevElement,
focusFirstElement,
Expand All @@ -22,10 +24,12 @@ const isFreeFocus = () => focusOnBody() || focusIsHidden();

let lastActiveTrap = null;
let lastActiveFocus = null;
let tryRestoreFocus = () => null;

let lastPortaledElement = null;

let focusWasOutsideWindow = false;
let windowFocused = false;

const defaultWhitelist = () => true;

Expand Down Expand Up @@ -86,23 +90,61 @@ const withinHost = (activeElement, workingArea) => (
workingArea.some(area => checkInHost(activeElement, area, area))
);

const getNodeFocusables = nodes => getFocusableNodes(nodes, new Map());
const isNotFocusable = node => (
!getNodeFocusables([node.parentNode]).some(el => el.node === node)
);

const activateTrap = () => {
let result = false;
if (lastActiveTrap) {
const {
observed, persistentFocus, autoFocus, shards, crossFrame, focusOptions,
observed, persistentFocus, autoFocus, shards, crossFrame, focusOptions, noFocusGuards,
} = lastActiveTrap;
const workingNode = observed || (lastPortaledElement && lastPortaledElement.portaledElement);

// check if lastActiveFocus is still reachable
if (focusOnBody() && lastActiveFocus) {
if (
// it was removed
!document.body.contains(lastActiveFocus)
// or not focusable (this is expensive operation)!
|| isNotFocusable(lastActiveFocus)
) {
lastActiveFocus = null;

const newTarget = tryRestoreFocus();
if (newTarget) {
newTarget.focus();
}
}
}

const activeElement = document && document.activeElement;
if (workingNode) {
const workingArea = [
workingNode,
...shards.map(extractRef).filter(Boolean),
];

const shouldForceRestoreFocus = () => {
// force restoration happens when
// - focus is not inside now
// - focusWasOutside
// - there are go guards
// - the last active element was the first or the last focusable one
if (!focusWasOutside(crossFrame) || !noFocusGuards || !lastActiveFocus || windowFocused) {
return false;
}
const nodes = getNodeFocusables(workingArea);
const lastIndex = nodes.findIndex(({ node }) => node === lastActiveFocus);

return lastIndex === 0 || lastIndex === nodes.length - 1;
};

if (!activeElement || focusWhitelisted(activeElement)) {
if (
(persistentFocus || focusWasOutside(crossFrame))
(persistentFocus || shouldForceRestoreFocus())
|| !isFreeFocus()
|| (!lastActiveFocus && autoFocus)
) {
Expand Down Expand Up @@ -130,11 +172,12 @@ const activateTrap = () => {
}
focusWasOutsideWindow = false;
lastActiveFocus = document && document.activeElement;
tryRestoreFocus = captureFocusRestore(lastActiveFocus);
}
}

if (document
// element was changed
// element was changed by moveFocusInside
&& activeElement !== document.activeElement
// fast check for any auto-guard
&& document.querySelector('[data-focus-auto-guard]')) {
Expand Down Expand Up @@ -189,7 +232,11 @@ FocusTrap.propTypes = {
children: PropTypes.node.isRequired,
};

const onWindowFocus = () => {
windowFocused = true;
};
const onWindowBlur = () => {
windowFocused = false;
focusWasOutsideWindow = 'just';
// using setTimeout to set this variable after React/sidecar reaction
deferAction(() => {
Expand All @@ -200,12 +247,14 @@ const onWindowBlur = () => {
const attachHandler = () => {
document.addEventListener('focusin', onTrap);
document.addEventListener('focusout', onBlur);
window.addEventListener('focus', onWindowFocus);
window.addEventListener('blur', onWindowBlur);
};

const detachHandler = () => {
document.removeEventListener('focusin', onTrap);
document.removeEventListener('focusout', onBlur);
window.removeEventListener('focus', onWindowFocus);
window.removeEventListener('blur', onWindowBlur);
};

Expand Down
16 changes: 15 additions & 1 deletion stories/Iframe.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,23 @@ export const IFrame = props => (
{' '}
outside
</div>
<hr />
<Trap {...props} />
<hr />

<Trap {...props}>
<iframe src={`/iframe.html?id=focus-lock--codesandbox-example&crossFrame=${props.crossFrame}`} style={{ width: '100%', height: '400px' }} />
<iframe
src={`/iframe.html?id=focus-lock--codesandbox-example&crossFrame=${props.crossFrame}`}
style={{ width: '100%', height: '400px' }}
/>
<iframe
src={`/iframe.html?id=focus-lock--codesandbox-example&crossFrame=${props.crossFrame}`}
style={{ width: '100%', height: '400px' }}
/>
</Trap>
<hr />
<Trap {...props} />
<hr />
<div style={bg}>
{' '}
Inaccessible
Expand Down

0 comments on commit 29d2c8b

Please sign in to comment.