Skip to content

Commit

Permalink
React Events: add onFocusVisibleChange to Focus (#15516)
Browse files Browse the repository at this point in the history
Called when focus visibility changes. Focus is only considered visible if a
focus event occurs after keyboard navigation. This provides a way for people to
provide visual focus styles for keyboard accessible UIs without those styles
appearing if focus is triggered by mouse, touch, pen.
  • Loading branch information
necolas authored Apr 29, 2019
1 parent cc5a493 commit 2cca187
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 20 deletions.
39 changes: 27 additions & 12 deletions packages/react-events/docs/Focus.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
# Focus

The `Focus` module responds to focus and blur events on its child. Focus events
are dispatched for `mouse`, `pen`, `touch`, and `keyboard`
pointer types.
are dispatched for all input types, with the exception of `onFocusVisibleChange`
which is only dispatched when focusing with a keyboard.

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

```js
// Example
const TextField = (props) => (
<Focus
onBlur={props.onBlur}
onFocus={props.onFocus}
>
<textarea></textarea>
</Focus>
);
const Button = (props) => {
const [ focusVisible, setFocusVisible ] = useState(false);

return (
<Focus
onBlur={props.onBlur}
onFocus={props.onFocus}
onFocusVisibleChange={setFocusVisible}
>
<button
children={props.children}
style={{
...(focusVisible && focusVisibleStyles)
}}
>
</Focus>
);
};
```

## Types

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

Expand All @@ -43,5 +53,10 @@ Called when the element gains focus.

### onFocusChange: boolean => void

Called when the element changes hover state (i.e., after `onBlur` and
Called when the element changes focus state (i.e., after `onBlur` and
`onFocus`).

### onFocusVisibleChange: boolean => void

Called when the element receives or loses focus following keyboard navigation.
This can be used to display focus styles only for keyboard interactions.
107 changes: 103 additions & 4 deletions packages/react-events/src/Focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ type FocusProps = {
onBlur: (e: FocusEvent) => void,
onFocus: (e: FocusEvent) => void,
onFocusChange: boolean => void,
onFocusVisibleChange: boolean => void,
};

type FocusState = {
isFocused: boolean,
focusTarget: null | Element | Document,
isFocused: boolean,
isLocalFocusVisible: boolean,
};

type FocusEventType = 'focus' | 'blur' | 'focuschange';
type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';

type FocusEvent = {|
target: Element | Document,
Expand All @@ -38,6 +40,21 @@ const targetEventTypes = [
{name: 'blur', passive: true, capture: true},
];

const rootEventTypes = [
'keydown',
'keypress',
'keyup',
'mousemove',
'mousedown',
'mouseup',
'pointermove',
'pointerdown',
'pointerup',
'touchmove',
'touchstart',
'touchend',
];

function createFocusEvent(
type: FocusEventType,
target: Element | Document,
Expand Down Expand Up @@ -65,6 +82,13 @@ function dispatchFocusInEvents(
const syntheticEvent = createFocusEvent('focuschange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
}
if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
const listener = () => {
props.onFocusVisibleChange(true);
};
const syntheticEvent = createFocusEvent('focusvisiblechange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
}
}

function dispatchFocusOutEvents(
Expand All @@ -84,6 +108,23 @@ function dispatchFocusOutEvents(
const syntheticEvent = createFocusEvent('focuschange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
}
dispatchFocusVisibleOutEvent(context, props, state);
}

function dispatchFocusVisibleOutEvent(
context: ReactResponderContext,
props: FocusProps,
state: FocusState,
) {
const target = ((state.focusTarget: any): Element | Document);
if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
const listener = () => {
props.onFocusVisibleChange(false);
};
const syntheticEvent = createFocusEvent('focusvisiblechange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
state.isLocalFocusVisible = false;
}
}

function unmountResponder(
Expand All @@ -96,12 +137,16 @@ function unmountResponder(
}
}

let isGlobalFocusVisible = true;

const FocusResponder = {
targetEventTypes,
rootEventTypes,
createInitialState(): FocusState {
return {
isFocused: false,
focusTarget: null,
isFocused: false,
isLocalFocusVisible: false,
};
},
stopLocalPropagation: true,
Expand Down Expand Up @@ -129,8 +174,9 @@ const FocusResponder = {
// Browser focus is not expected to bubble.
state.focusTarget = getEventCurrentTarget(event, context);
if (state.focusTarget === target) {
dispatchFocusInEvents(context, props, state);
state.isFocused = true;
state.isLocalFocusVisible = isGlobalFocusVisible;
dispatchFocusInEvents(context, props, state);
}
}
break;
Expand All @@ -145,6 +191,59 @@ const FocusResponder = {
}
}
},
onRootEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: FocusProps,
state: FocusState,
): void {
const {type, target} = event;

switch (type) {
case 'mousemove':
case 'mousedown':
case 'mouseup':
case 'pointermove':
case 'pointerdown':
case 'pointerup':
case 'touchmove':
case 'touchstart':
case 'touchend': {
// Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
// element when the window blurs.
if (type === 'mousemove' && target.nodeName === 'HTML') {
return;
}

isGlobalFocusVisible = false;

// Focus should stop being visible if a pointer is used on the element
// after it was focused using a keyboard.
if (
state.focusTarget === getEventCurrentTarget(event, context) &&
(type === 'mousedown' ||
type === 'touchstart' ||
type === 'pointerdown')
) {
dispatchFocusVisibleOutEvent(context, props, state);
}
break;
}

case 'keydown':
case 'keypress':
case 'keyup': {
const nativeEvent = event.nativeEvent;
if (
nativeEvent.key === 'Tab' &&
!(nativeEvent.metaKey || nativeEvent.altKey || nativeEvent.ctrlKey)
) {
isGlobalFocusVisible = true;
}
break;
}
}
},
onUnmount(
context: ReactResponderContext,
props: FocusProps,
Expand Down
68 changes: 68 additions & 0 deletions packages/react-events/src/__tests__/Focus-test.internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ const createFocusEvent = type => {
return event;
};

const createKeyboardEvent = (type, data) => {
return new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
...data,
});
};

const createPointerEvent = (type, data) => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent(type, true, true);
if (data != null) {
Object.entries(data).forEach(([key, value]) => {
event[key] = value;
});
}
return event;
};

describe('Focus event responder', () => {
let container;

Expand Down Expand Up @@ -138,6 +157,55 @@ describe('Focus event responder', () => {
});
});

describe('onFocusVisibleChange', () => {
let onFocusVisibleChange, ref;

beforeEach(() => {
onFocusVisibleChange = jest.fn();
ref = React.createRef();
const element = (
<Focus onFocusVisibleChange={onFocusVisibleChange}>
<div ref={ref} />
</Focus>
);
ReactDOM.render(element, container);
});

it('is called after "focus" and "blur" if keyboard navigation is active', () => {
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(createFocusEvent('focus'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
ref.current.dispatchEvent(createFocusEvent('blur'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
});

it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(createFocusEvent('focus'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
// then use pointer on the target, focus should no longer be visible
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
// onFocusVisibleChange should not be called again
ref.current.dispatchEvent(createFocusEvent('blur'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
});

it('is not called after "focus" and "blur" events without keyboard', () => {
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(createFocusEvent('focus'));
container.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(createFocusEvent('blur'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
});
});

describe('nested Focus components', () => {
it('do not propagate events by default', () => {
const events = [];
Expand Down
8 changes: 4 additions & 4 deletions packages/react-events/src/__tests__/Press-test.internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -1090,10 +1090,10 @@ describe('Event responder: Press', () => {
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
ref.current.dispatchEvent(
container.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
ref.current.dispatchEvent(
container.dispatchEvent(
createPointerEvent('pointerup', coordinatesOutside),
);
jest.runAllTimers();
Expand Down Expand Up @@ -1135,13 +1135,13 @@ describe('Event responder: Press', () => {
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
ref.current.dispatchEvent(
container.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
jest.runAllTimers();
expect(events).toEqual(['onPressMove']);
events = [];
ref.current.dispatchEvent(
container.dispatchEvent(
createPointerEvent('pointerup', coordinatesOutside),
);
jest.runAllTimers();
Expand Down

0 comments on commit 2cca187

Please sign in to comment.