Skip to content

Commit 2cca187

Browse files
authored
React Events: add onFocusVisibleChange to Focus (#15516)
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.
1 parent cc5a493 commit 2cca187

File tree

4 files changed

+202
-20
lines changed

4 files changed

+202
-20
lines changed

packages/react-events/docs/Focus.md

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,39 @@
11
# Focus
22

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

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

99
```js
1010
// Example
11-
const TextField = (props) => (
12-
<Focus
13-
onBlur={props.onBlur}
14-
onFocus={props.onFocus}
15-
>
16-
<textarea></textarea>
17-
</Focus>
18-
);
11+
const Button = (props) => {
12+
const [ focusVisible, setFocusVisible ] = useState(false);
13+
14+
return (
15+
<Focus
16+
onBlur={props.onBlur}
17+
onFocus={props.onFocus}
18+
onFocusVisibleChange={setFocusVisible}
19+
>
20+
<button
21+
children={props.children}
22+
style={{
23+
...(focusVisible && focusVisibleStyles)
24+
}}
25+
>
26+
</Focus>
27+
);
28+
};
1929
```
2030

2131
## Types
2232

2333
```js
2434
type FocusEvent = {
2535
target: Element,
26-
type: 'blur' | 'focus' | 'focuschange'
36+
type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange'
2737
}
2838
```
2939

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

4454
### onFocusChange: boolean => void
4555

46-
Called when the element changes hover state (i.e., after `onBlur` and
56+
Called when the element changes focus state (i.e., after `onBlur` and
4757
`onFocus`).
58+
59+
### onFocusVisibleChange: boolean => void
60+
61+
Called when the element receives or loses focus following keyboard navigation.
62+
This can be used to display focus styles only for keyboard interactions.

packages/react-events/src/Focus.js

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ type FocusProps = {
1919
onBlur: (e: FocusEvent) => void,
2020
onFocus: (e: FocusEvent) => void,
2121
onFocusChange: boolean => void,
22+
onFocusVisibleChange: boolean => void,
2223
};
2324

2425
type FocusState = {
25-
isFocused: boolean,
2626
focusTarget: null | Element | Document,
27+
isFocused: boolean,
28+
isLocalFocusVisible: boolean,
2729
};
2830

29-
type FocusEventType = 'focus' | 'blur' | 'focuschange';
31+
type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
3032

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

43+
const rootEventTypes = [
44+
'keydown',
45+
'keypress',
46+
'keyup',
47+
'mousemove',
48+
'mousedown',
49+
'mouseup',
50+
'pointermove',
51+
'pointerdown',
52+
'pointerup',
53+
'touchmove',
54+
'touchstart',
55+
'touchend',
56+
];
57+
4158
function createFocusEvent(
4259
type: FocusEventType,
4360
target: Element | Document,
@@ -65,6 +82,13 @@ function dispatchFocusInEvents(
6582
const syntheticEvent = createFocusEvent('focuschange', target);
6683
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
6784
}
85+
if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
86+
const listener = () => {
87+
props.onFocusVisibleChange(true);
88+
};
89+
const syntheticEvent = createFocusEvent('focusvisiblechange', target);
90+
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
91+
}
6892
}
6993

7094
function dispatchFocusOutEvents(
@@ -84,6 +108,23 @@ function dispatchFocusOutEvents(
84108
const syntheticEvent = createFocusEvent('focuschange', target);
85109
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
86110
}
111+
dispatchFocusVisibleOutEvent(context, props, state);
112+
}
113+
114+
function dispatchFocusVisibleOutEvent(
115+
context: ReactResponderContext,
116+
props: FocusProps,
117+
state: FocusState,
118+
) {
119+
const target = ((state.focusTarget: any): Element | Document);
120+
if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
121+
const listener = () => {
122+
props.onFocusVisibleChange(false);
123+
};
124+
const syntheticEvent = createFocusEvent('focusvisiblechange', target);
125+
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
126+
state.isLocalFocusVisible = false;
127+
}
87128
}
88129

89130
function unmountResponder(
@@ -96,12 +137,16 @@ function unmountResponder(
96137
}
97138
}
98139

140+
let isGlobalFocusVisible = true;
141+
99142
const FocusResponder = {
100143
targetEventTypes,
144+
rootEventTypes,
101145
createInitialState(): FocusState {
102146
return {
103-
isFocused: false,
104147
focusTarget: null,
148+
isFocused: false,
149+
isLocalFocusVisible: false,
105150
};
106151
},
107152
stopLocalPropagation: true,
@@ -129,8 +174,9 @@ const FocusResponder = {
129174
// Browser focus is not expected to bubble.
130175
state.focusTarget = getEventCurrentTarget(event, context);
131176
if (state.focusTarget === target) {
132-
dispatchFocusInEvents(context, props, state);
133177
state.isFocused = true;
178+
state.isLocalFocusVisible = isGlobalFocusVisible;
179+
dispatchFocusInEvents(context, props, state);
134180
}
135181
}
136182
break;
@@ -145,6 +191,59 @@ const FocusResponder = {
145191
}
146192
}
147193
},
194+
onRootEvent(
195+
event: ReactResponderEvent,
196+
context: ReactResponderContext,
197+
props: FocusProps,
198+
state: FocusState,
199+
): void {
200+
const {type, target} = event;
201+
202+
switch (type) {
203+
case 'mousemove':
204+
case 'mousedown':
205+
case 'mouseup':
206+
case 'pointermove':
207+
case 'pointerdown':
208+
case 'pointerup':
209+
case 'touchmove':
210+
case 'touchstart':
211+
case 'touchend': {
212+
// Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
213+
// element when the window blurs.
214+
if (type === 'mousemove' && target.nodeName === 'HTML') {
215+
return;
216+
}
217+
218+
isGlobalFocusVisible = false;
219+
220+
// Focus should stop being visible if a pointer is used on the element
221+
// after it was focused using a keyboard.
222+
if (
223+
state.focusTarget === getEventCurrentTarget(event, context) &&
224+
(type === 'mousedown' ||
225+
type === 'touchstart' ||
226+
type === 'pointerdown')
227+
) {
228+
dispatchFocusVisibleOutEvent(context, props, state);
229+
}
230+
break;
231+
}
232+
233+
case 'keydown':
234+
case 'keypress':
235+
case 'keyup': {
236+
const nativeEvent = event.nativeEvent;
237+
if (
238+
nativeEvent.key === 'Tab' &&
239+
!(nativeEvent.metaKey || nativeEvent.altKey || nativeEvent.ctrlKey)
240+
) {
241+
isGlobalFocusVisible = true;
242+
}
243+
break;
244+
}
245+
}
246+
},
148247
onUnmount(
149248
context: ReactResponderContext,
150249
props: FocusProps,

packages/react-events/src/__tests__/Focus-test.internal.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ const createFocusEvent = type => {
2020
return event;
2121
};
2222

23+
const createKeyboardEvent = (type, data) => {
24+
return new KeyboardEvent(type, {
25+
bubbles: true,
26+
cancelable: true,
27+
...data,
28+
});
29+
};
30+
31+
const createPointerEvent = (type, data) => {
32+
const event = document.createEvent('CustomEvent');
33+
event.initCustomEvent(type, true, true);
34+
if (data != null) {
35+
Object.entries(data).forEach(([key, value]) => {
36+
event[key] = value;
37+
});
38+
}
39+
return event;
40+
};
41+
2342
describe('Focus event responder', () => {
2443
let container;
2544

@@ -138,6 +157,55 @@ describe('Focus event responder', () => {
138157
});
139158
});
140159

160+
describe('onFocusVisibleChange', () => {
161+
let onFocusVisibleChange, ref;
162+
163+
beforeEach(() => {
164+
onFocusVisibleChange = jest.fn();
165+
ref = React.createRef();
166+
const element = (
167+
<Focus onFocusVisibleChange={onFocusVisibleChange}>
168+
<div ref={ref} />
169+
</Focus>
170+
);
171+
ReactDOM.render(element, container);
172+
});
173+
174+
it('is called after "focus" and "blur" if keyboard navigation is active', () => {
175+
// use keyboard first
176+
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
177+
ref.current.dispatchEvent(createFocusEvent('focus'));
178+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
179+
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
180+
ref.current.dispatchEvent(createFocusEvent('blur'));
181+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
182+
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
183+
});
184+
185+
it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
186+
// use keyboard first
187+
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
188+
ref.current.dispatchEvent(createFocusEvent('focus'));
189+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
190+
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
191+
// then use pointer on the target, focus should no longer be visible
192+
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
193+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
194+
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
195+
// onFocusVisibleChange should not be called again
196+
ref.current.dispatchEvent(createFocusEvent('blur'));
197+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
198+
});
199+
200+
it('is not called after "focus" and "blur" events without keyboard', () => {
201+
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
202+
ref.current.dispatchEvent(createFocusEvent('focus'));
203+
container.dispatchEvent(createPointerEvent('pointerdown'));
204+
ref.current.dispatchEvent(createFocusEvent('blur'));
205+
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
206+
});
207+
});
208+
141209
describe('nested Focus components', () => {
142210
it('do not propagate events by default', () => {
143211
const events = [];

packages/react-events/src/__tests__/Press-test.internal.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,10 +1090,10 @@ describe('Event responder: Press', () => {
10901090
ref.current.dispatchEvent(
10911091
createPointerEvent('pointermove', coordinatesInside),
10921092
);
1093-
ref.current.dispatchEvent(
1093+
container.dispatchEvent(
10941094
createPointerEvent('pointermove', coordinatesOutside),
10951095
);
1096-
ref.current.dispatchEvent(
1096+
container.dispatchEvent(
10971097
createPointerEvent('pointerup', coordinatesOutside),
10981098
);
10991099
jest.runAllTimers();
@@ -1135,13 +1135,13 @@ describe('Event responder: Press', () => {
11351135
ref.current.dispatchEvent(
11361136
createPointerEvent('pointermove', coordinatesInside),
11371137
);
1138-
ref.current.dispatchEvent(
1138+
container.dispatchEvent(
11391139
createPointerEvent('pointermove', coordinatesOutside),
11401140
);
11411141
jest.runAllTimers();
11421142
expect(events).toEqual(['onPressMove']);
11431143
events = [];
1144-
ref.current.dispatchEvent(
1144+
container.dispatchEvent(
11451145
createPointerEvent('pointerup', coordinatesOutside),
11461146
);
11471147
jest.runAllTimers();

0 commit comments

Comments
 (0)