Skip to content

Commit

Permalink
React Events: add onFocusVisibleChange to Focus
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 committed Apr 26, 2019
1 parent cc5a493 commit efb1149
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 17 deletions.
33 changes: 24 additions & 9 deletions packages/react-events/docs/Focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,24 @@ 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
Expand Down Expand Up @@ -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.
103 changes: 99 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,55 @@ 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': {
if (target.nodeName === 'HTML') {
return;
}

isGlobalFocusVisible = false;

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 efb1149

Please sign in to comment.