Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: auto-focus in case of activeElement disappearance, fixes #321 #324

Merged
merged 2 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading