Skip to content

Commit

Permalink
React Events: FocusScope tweaks and docs (#15515)
Browse files Browse the repository at this point in the history
* FocusScope: rename trap to contain.
* FocusScope: avoid potential for el.focus() errors.
* FocusScope: add docs.
* Update docs formatting.
  • Loading branch information
necolas authored Apr 26, 2019
1 parent 796c67a commit cc5a493
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 33 deletions.
9 changes: 6 additions & 3 deletions packages/react-events/docs/Focus.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Focus
# Focus

The `Focus` module responds to focus and blur events on its child. Focus events
are dispatched for `mouse`, `pen`, `touch`, and `keyboard`
Expand All @@ -18,15 +18,18 @@ const TextField = (props) => (
);
```

## Types

```js
// Types
type FocusEvent = {
target: Element,
type: 'blur' | 'focus' | 'focuschange'
}
```

### disabled: boolean
## Props

### disabled: boolean = false

Disables all `Focus` events.

Expand Down
37 changes: 37 additions & 0 deletions packages/react-events/docs/FocusScope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# FocusScope

The `FocusScope` module can be used to manage focus within its subtree.

```js
// Example
const Modal = () => (
<FocusScope
autoFocus={true}
contain={true}
restoreFocus={true}
>
<h1>Focus contained within modal</h1>
<input placeholder="Focusable input" />
<div role="button" tabIndex={0}>Focusable element</div>
<input placeholder="Non-focusable input" tabIndex={-1} />
<Press onPress={onPressClose}>
<div role="button" tabIndex={0}>Close</div>
</Press>
</FocusScope>
);
```

## Props

### autoFocus: boolean = false

Automatically moves focus to the first focusable element within scope.

### contain: boolean = false

Contain focus within the subtree of the `FocusScope` instance.

### restoreFocus: boolean = false

Automatically restores focus to element that was last focused before focus moved
within the scope.
11 changes: 7 additions & 4 deletions packages/react-events/docs/Hover.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
## Hover
# Hover

The `Hover` module responds to hover events on the element it wraps. Hover
events are only dispatched for `mouse` pointer types. Hover begins when the
pointer enters the element's bounds and ends when the pointer leaves.
events are only dispatched for `mouse` and `pen` pointer types. Hover begins
when the pointer enters the element's bounds and ends when the pointer leaves.

Hover events do not propagate between `Hover` event responders.

Expand All @@ -25,15 +25,18 @@ const Link = (props) => (
);
```

## Types

```js
// Types
type HoverEvent = {
pointerType: 'mouse' | 'pen',
target: Element,
type: 'hoverstart' | 'hoverend' | 'hovermove' | 'hoverchange'
}
```

## Props

### delayHoverEnd: number

The duration of the delay between when hover ends and when `onHoverEnd` is
Expand Down
19 changes: 11 additions & 8 deletions packages/react-events/docs/Press.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Press
# Press

The `Press` module responds to press events on the element it wraps. Press
events are dispatched for `mouse`, `pen`, `touch`, and `keyboard` pointer types.
Expand Down Expand Up @@ -33,22 +33,25 @@ const Button = (props) => (
);
```

## Types

```js
// Types
type PressEvent = {
pointerType: 'mouse' | 'touch' | 'pen' | 'keyboard',
target: Element,
type: 'press' | 'pressstart' | 'pressend' | 'presschange' | 'pressmove' | 'longpress' | 'longpresschange'
}

type PressOffset = {
top: number,
right: number,
bottom: number,
right: number
top?: number,
right?: number,
bottom?: number,
right?: number
};
```

## Props

### delayLongPress: number = 500ms

The duration of a press before `onLongPress` and `onLongPressChange` are called.
Expand All @@ -64,7 +67,7 @@ The duration of a delay between when the press starts and when `onPressStart` is
called. This delay is cut short (and `onPressStart` is called) if the press is
released before the threshold is exceeded.

### disabled: boolean
### disabled: boolean = false

Disables all `Press` events.

Expand Down Expand Up @@ -118,7 +121,7 @@ Called once the element is pressed down. If the press is released before the

Defines how far the pointer (while held down) may move outside the bounds of the
element before it is deactivated. Ensure you pass in a constant to reduce memory
allocations.
allocations. Default is `20` for each offset.

### preventDefault: boolean = true

Expand Down
37 changes: 23 additions & 14 deletions packages/react-events/src/FocusScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';

type FocusScopeProps = {
autoFocus: Boolean,
contain: Boolean,
restoreFocus: Boolean,
trap: Boolean,
};

type FocusScopeState = {
Expand All @@ -27,14 +27,21 @@ type FocusScopeState = {
const targetEventTypes = [{name: 'keydown', passive: false}];
const rootEventTypes = [{name: 'focus', passive: true, capture: true}];

function focusFirstChildEventTarget(
function focusElement(element: ?HTMLElement) {
if (element != null) {
try {
element.focus();
} catch (err) {}
}
}

function getFirstFocusableElement(
context: ReactResponderContext,
state: FocusScopeState,
): void {
): ?HTMLElement {
const elements = context.getFocusableElementsInScope();
if (elements.length > 0) {
const firstElement = elements[0];
firstElement.focus();
return elements[0];
}
}

Expand Down Expand Up @@ -78,7 +85,7 @@ const FocusScopeResponder = {

if (shiftKey) {
if (position === 0) {
if (props.trap) {
if (props.contain) {
nextElement = elements[lastPosition];
} else {
// Out of bounds
Expand All @@ -90,7 +97,7 @@ const FocusScopeResponder = {
}
} else {
if (position === lastPosition) {
if (props.trap) {
if (props.contain) {
nextElement = elements[0];
} else {
// Out of bounds
Expand All @@ -107,7 +114,7 @@ const FocusScopeResponder = {
if (!context.isTargetWithinEventResponderScope(nextElement)) {
context.releaseOwnership();
}
nextElement.focus();
focusElement(nextElement);
state.currentFocusedNode = nextElement;
((nativeEvent: any): KeyboardEvent).preventDefault();
}
Expand All @@ -122,14 +129,15 @@ const FocusScopeResponder = {
) {
const {target} = event;

// Handle global trapping
if (props.trap) {
// Handle global focus containment
if (props.contain) {
if (!context.isTargetWithinEventComponent(target)) {
const currentFocusedNode = state.currentFocusedNode;
if (currentFocusedNode !== null) {
currentFocusedNode.focus();
focusElement(currentFocusedNode);
} else if (props.autoFocus) {
focusFirstChildEventTarget(context, state);
const firstElement = getFirstFocusableElement(context, state);
focusElement(firstElement);
}
}
}
Expand All @@ -143,7 +151,8 @@ const FocusScopeResponder = {
state.nodeToRestore = context.getActiveDocument().activeElement;
}
if (props.autoFocus) {
focusFirstChildEventTarget(context, state);
const firstElement = getFirstFocusableElement(context, state);
focusElement(firstElement);
}
},
onUnmount(
Expand All @@ -156,7 +165,7 @@ const FocusScopeResponder = {
state.nodeToRestore !== null &&
context.hasOwnership()
) {
state.nodeToRestore.focus();
focusElement(state.nodeToRestore);
}
},
onOwnershipChange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@ describe('FocusScope event responder', () => {
expect(document.activeElement).toBe(divRef.current);
});

it('should work as expected with autofocus and trapping', () => {
it('should work as expected with autoFocus and contain', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();

const SimpleFocusScope = () => (
<div>
<FocusScope autoFocus={true} trap={true}>
<FocusScope autoFocus={true} contain={true}>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
Expand Down Expand Up @@ -154,7 +154,7 @@ describe('FocusScope event responder', () => {
expect(document.activeElement).toBe(button2Ref.current);
});

it('should work as expected when nested with scope that is trapped', () => {
it('should work as expected when nested with scope that is contained', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
Expand All @@ -167,7 +167,7 @@ describe('FocusScope event responder', () => {
<FocusScope>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<FocusScope trap={true}>
<FocusScope contain={true}>
<button ref={button2Ref} id={2} />
<button ref={button3Ref} id={3} />
</FocusScope>
Expand Down

0 comments on commit cc5a493

Please sign in to comment.