Skip to content

Commit

Permalink
feat: no tail guard
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Apr 22, 2019
1 parent 3aaab0a commit c0c5fde
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 50 deletions.
103 changes: 61 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,42 @@
<h1 align="center">REACT FOCUS LOCK</h1>
<img src="./assets/ackbar.png" alt="it-is-a-trap" width="200" height="200" align="right">

The way to manage your focus.<br/>
The way to lock it inside.<br/>
The way to team up with a11y.<br/> <br/>
Make you site a better place. For everyone.<br/>
It is a trap! For:
- browser friendly focus lock<br/>
- matching your use cases<br/>
- trusted by best UI frameworks

[![CircleCI status](https://img.shields.io/circleci/project/github/theKashey/react-focus-lock/master.svg?style=flat-square)](https://circleci.com/gh/theKashey/react-focus-lock/tree/master)
[![npm](https://img.shields.io/npm/v/react-focus-lock.svg)](https://www.npmjs.com/package/react-focus-lock)

[![bundle size](https://badgen.net/bundlephobia/minzip/react-focus-lock)](https://bundlephobia.com/result?p=react-focus-lock)
![downloads](https://badgen.net/npm/dm/react-focus-lock)
<hr/>
</div>


It is a trap! We got your focus and will not let him out!

[![NPM](https://nodei.co/npm/react-focus-lock.png?downloads=true&stars=true)](https://nodei.co/npm/react-focus-lock/)

This is a small library, but very useful for:
- Modal dialogs. You can not leave it with "Tab", ie do a "tab-out".
- Focused tasks. It will aways brings you back, as you can "lock" user inside a component.
- Modal dialogs. You can not leave it with "Tab", ie do a "tab-out".
- Focused tasks. It will aways brings you back, as you can "lock" user inside a component.
- You have to lock _every_ modal dialog, that's what `a11y` is asking for.

### Trusted
Trusted by
[Atlassian AtlasKit](https://atlaskit.atlassian.com),
[ReachUI](https://ui.reach.tech/),
[SmoothUI](https://smooth-ui.smooth-code.com/),
[Storybook](https://storybook.js.org/)
and we will do out best to earn your trust to!

You have to lock _every_ modal dialog, that's what `a11y` is asking for.

And this is most comprehensive focus lock/trap ever built.

# Features
- no keyboard control, everything is done watching a __focus behavior__, not emulating it. Thus works always and everywhere.
- React __Portals__ support. Even if some data is in outerspace - it is [still in lock](https://github.com/theKashey/react-focus-lock/issues/19).
- React __Portals__ support. Even if some data is in outer space - it is [still in lock](https://github.com/theKashey/react-focus-lock/issues/19).
- _Scattered_ locks, or focus lock groups - you can setup different isolated locks, and _tab_ from one to another.
- Controllable isolation level.

> 💡 __focus__ locks is only the first part, there are also __scroll lock__ and __text-to-speech__ lock
you have to use to really "lock" the user.
Try [react-focus-on](https://github.com/theKashey/react-focus-on) to archive everything above, assembled in the right order.

# How to use
Just wrap something with focus lock, and focus will be `moved inside` on mount.
```js
Expand All @@ -56,20 +62,22 @@ This one is about managing the focus.
I've got a good [article about focus management, dialogs and WAI-ARIA](https://medium.com/@antonkorzunov/its-a-focus-trap-699a04d66fb5).

# API
FocusLock has few props to tune behavior
- `disabled`, to disable(enable) behavior without altering the tree.
> FocusLock would work perfectly even with no props set.
FocusLock has few props to tune behavior, all props are optional:
- `[disabled`, to disable(enable) behavior without altering the tree.
- `returnFocus`, to return focus into initial position on unmount(not disable).
> By default `returnFocus` is disabled, so FocusLock will not restore original focus on deactivation.
This is expected behavior for Modals, but it is better to implement it by your self.
- `persistentFocus`, default false, requires any element to be focused. This also disables text selections inside, and __outside__ focus lock.
- `autoFocus`, default true, enables or disables focusing into on Lock activation. If disabled Lock will blur an active focus.
- `noFocusGuards` disabled _focus guards_ - virtual inputs which secure tab index.
- `group` named focus group for focus scattering aka [combined lock targets](https://github.com/theKashey/vue-focus-lock/issues/2)
- `shards` an array of `ref` pointing to the nodes, which focus lock should consider and a part of it. This is another way focus scattering.
- `whiteList` you could _whitelist_ locations FocusLock should carry about. Everything outside it will ignore. For example - any modals.
- `as` if you need to change internal `div` element, to any other. Use ref forwarding to give FocusLock the node to work with.
- `lockProps` to pass any extra props (except className) to the internal wrapper.
- `persistentFocus=false`, requires any element to be focused. This also disables text selections inside, and __outside__ focus lock.
- `autoFocus=true`, enables or disables focusing into on Lock activation. If disabled Lock will blur an active focus.
- `noFocusGuards=false` disabled _focus guards_ - virtual inputs which secure tab index.
- `group='''` named focus group for focus scattering aka [combined lock targets](https://github.com/theKashey/vue-focus-lock/issues/2)
- `shards=[]` an array of `ref` pointing to the nodes, which focus lock should consider and a part of it. This is another way focus scattering.
- `whiteList=fn` you could _whitelist_ locations FocusLock should carry about. Everything outside it will ignore. For example - any modals.
- `as='div'` if you need to change internal `div` element, to any other. Use ref forwarding to give FocusLock the node to work with.
- `lockProps={}` to pass any extra props (except className) to the internal wrapper.

### Focusing in OSX (Safary/FireFox) is strange!
By default `tabbing` in OSX `sees` only controls, but not links or anything else `tabbable`. This is system settings, and Safari/FireFox obey.
Expand Down Expand Up @@ -98,18 +106,8 @@ Press Option+Tab in Safary to loop across all tabbables, or change the Safary se
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>
```

<FocusLock as="section">
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>

<FocusLock as={AnotherComponent} lockProps={{anyProp: 4}}>
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>
```

If there is more than one auto-focusable target - the first will be selected.
If it is a part of radio group, and __rest of radio group element are also autofocusable__(just put them into AutoFocusInside) -
checked one fill be selected.
Expand Down Expand Up @@ -166,6 +164,20 @@ const PortaledElement = () => (
</FocusLock>
```

### Using your own `Components`
You may use `as` prop to change _what_ Focus-Lock will render around `children`.
```js
<FocusLock as="section">
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>

<FocusLock as={AnotherComponent} lockProps={{anyAnotherComponentProp: 4}}>
<button>Click</button>
<span>Hello there!</span>
</FocusLock>
```

### Guarding
As you may know - FocusLock is adding `Focus Guards` before and after lock to remove some side effects, like page scrolling.
But `shards` will not have such guards, and it might be not so cool to use them - for example if no `tabbable` would be
Expand All @@ -175,19 +187,26 @@ You may wrap shard with `InFocusGuard` or just drop `InFocusGuard` here and the
```js
import {InFocusGuard} from 'react-focus-lock';

// wrap with
<InFocusGuard>
<button>
<button />
</InFocusGuard>

//

// place before and after
<InFocusGuard />
<button>
<button />
<InFocusGuard />
```
InFocusGuards would be active(tabbable) only when tabble, it protecting, is focused.

### Automatic potral discovery
#### Removing Tailing Guard
If only your modal is the last tabble element on the body - you might remove the Tailing Guard,
to allow user _tab_ into address bar.
```js
<InFocusGuard/>
<button />
// there is no "tailing" guard :)
```

# Unmounting and focus management
- In case FocusLock has `returnFocus` enabled, and it's gonna to be unmounted - focus will be returned after zero-timeout.
Expand Down
2 changes: 1 addition & 1 deletion react-focus-lock.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ declare module 'react-focus-lock' {
/**
* disables hidden inputs before and after the lock.
*/
noFocusGuards?: boolean;
noFocusGuards?: boolean | "tail";

/**
* named focus group for focus scattering aka combined lock targets
Expand Down
7 changes: 5 additions & 2 deletions src/Lock.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,12 @@ class FocusLock extends Component {
...containerProps,
};

const hasLeadingGuards = noFocusGuards !== true;
const hasTailingGuards = hasLeadingGuards && (noFocusGuards !== 'tail');

return (
<Fragment>
{!noFocusGuards && [
{hasLeadingGuards && [
<div key="guard-first" data-focus-guard tabIndex={disabled ? -1 : 0} style={hiddenGuard} />, // nearest focus guard
<div key="guard-nearest" data-focus-guard tabIndex={disabled ? -1 : 1} style={hiddenGuard} />, // first tabbed element guard
]}
Expand All @@ -122,7 +125,7 @@ class FocusLock extends Component {
{children}
</Container>
{
!noFocusGuards &&
hasTailingGuards &&
<div data-focus-guard tabIndex={disabled ? -1 : 0} style={hiddenGuard} />
}
</Fragment>
Expand Down
15 changes: 14 additions & 1 deletion src/Trap.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ let lastActiveFocus = null;

let lastPortaledElement = null;

let focusWasOutsideWindow = false;

const defaultWhitelist = () => true;

const focusWhitelisted = activeElement => (
Expand Down Expand Up @@ -68,7 +70,11 @@ const activateTrap = () => {
];

if (!activeElement || focusWhitelisted(activeElement)) {
if (persistentFocus || !isFreeFocus() || (!lastActiveFocus && autoFocus)) {
if (
(persistentFocus || focusWasOutsideWindow) ||
!isFreeFocus() ||
(!lastActiveFocus && autoFocus)
) {
if (
workingNode &&
!(
Expand All @@ -83,6 +89,7 @@ const activateTrap = () => {
result = moveFocusInside(workingArea, lastActiveFocus);
lastPortaledElement = {};
}
focusWasOutsideWindow = false;
}
lastActiveFocus = document && document.activeElement;
}
Expand Down Expand Up @@ -141,14 +148,20 @@ FocusTrap.propTypes = {
children: PropTypes.node.isRequired,
};

const onWindowBlur = () => {
focusWasOutsideWindow = true;
};

const attachHandler = () => {
document.addEventListener('focusin', onTrap, true);
document.addEventListener('focusout', onBlur);
window.addEventListener('blur', onWindowBlur);
};

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

function reducePropsToState(propsList) {
Expand Down
2 changes: 1 addition & 1 deletion stories/Exotic.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class Video extends React.Component {
return (
<div>
<button onClick={this.toggle}>!ACTIVATE THE TRAP!</button>
<FocusLock disabled={disabled}>
<FocusLock disabled={disabled} whiteList={node => { console.log(node); return false; }}>

<button onClick={this.toggle}>deactivate</button>

Expand Down
33 changes: 33 additions & 0 deletions stories/Jump.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,37 @@ class Trap1 extends Component {
}
}

export class NoTailingGuard extends Component {
state = {
disabled: true
};

toggle = () => {
setTimeout(() => {
this.setState({disabled: !this.state.disabled});
}, 10);
};

render() {
const {disabled} = this.state;
return (
<div>
<button>Button</button>
<div style={{marginTop: '70vh'}}>
<button onClick={this.toggle}>!ACTIVATE THE TRAP!</button>
</div>
<p>probably would not work properly in storybook iframe</p>

{!disabled && <FocusLock returnFocus noFocusGuards="tail">
<button>BUTTON</button>
<a href='#'>link somethere</a> <br/>
<button onClick={this.toggle}>DEACTIVATE</button>
</FocusLock>
}
</div>
)
}
}


export default () => <div><Trap1/></div>;
7 changes: 4 additions & 3 deletions stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import AutoFocus from './Autofocus';
import ReturnFocus from './ReturnFocus';
import {Trap1, Trap2, Trap3, Trap4} from './Checkboxes';
import {TextSelectionEnabled, TextSelectionDisabled, TextSelectionTabIndexEnabled} from './TextSelection';
import JumpCase from './Jump';
import JumpCase, {NoTailingGuard} from './Jump';
import {GroupCase, ShardGroupCase} from './Group';
import {PortalCase, ShardPortalCase} from './Portal';
import {MUISelect, MUISelectWhite} from './MUI';
Expand Down Expand Up @@ -46,7 +46,8 @@ storiesOf('Text selection', module)
.add('tabindex -1', () => <Frame><TextSelectionTabIndexEnabled/></Frame>);

storiesOf('Jump', module)
.add('jump', () => <Frame><JumpCase/></Frame>);
.add('jump', () => <Frame><JumpCase/></Frame>)
.add('no tailing guard', () => <Frame><NoTailingGuard/></Frame>)

storiesOf('Portal', module)
.add('portal', () => <Frame><PortalCase/></Frame>)
Expand All @@ -72,4 +73,4 @@ storiesOf('Disabled', module)
.add('disabled element with tabindex', () => <Frame><DisabledFormWithTabIndex/></Frame>)

storiesOf('Excotic', module)
.add('video', () => <Frame><Video /></Frame>);
.add('video', () => <Frame><Video/></Frame>);

0 comments on commit c0c5fde

Please sign in to comment.