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

React Events: add onFocusVisibleChange to Focus #15516

Merged
merged 1 commit into from
Apr 29, 2019
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
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably not ideal to have global focus state being checked / modified in every instance. Should this be handled by a module outside of Flare (using native APIs) that we query for global focus visibility info? Any other ideas?

Copy link
Contributor

@trueadm trueadm Apr 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a context.isEventComponentWithinResponderScope or context.isResponderTopLevel API that checks if a parent event component in the tree has the same responder and bail-out of this logic?

}
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