Skip to content

Commit 2cca187

Browse files
author
Nicolas Gallagher
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

4 files changed

Lines changed: 202 additions & 20 deletions

File tree

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-
constTextField= (props) => (
12-
<Focus
13-
onBlur={props.onBlur}
14-
onFocus={props.onFocus}
15-
>
16-
<textarea></textarea>
17-
</Focus>
18-
);
11+
constButton= (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
typeFocusState={
25-
isFocused: boolean,
2626
focusTarget: null|Element|Document,
27+
isFocused: boolean,
28+
isLocalFocusVisible: boolean,
2729
};
2830

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

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

43+
constrootEventTypes=[
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
functioncreateFocusEvent(
4259
type: FocusEventType,
4360
target: Element|Document,
@@ -65,6 +82,13 @@ function dispatchFocusInEvents(
6582
constsyntheticEvent=createFocusEvent('focuschange',target);
6683
context.dispatchEvent(syntheticEvent,listener,{discrete: true});
6784
}
85+
if(props.onFocusVisibleChange&&state.isLocalFocusVisible){
86+
constlistener=()=>{
87+
props.onFocusVisibleChange(true);
88+
};
89+
constsyntheticEvent=createFocusEvent('focusvisiblechange',target);
90+
context.dispatchEvent(syntheticEvent,listener,{discrete: true});
91+
}
6892
}
6993

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

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

140+
letisGlobalFocusVisible=true;
141+
99142
constFocusResponder={
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+
constnativeEvent=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
returnevent;
2121
};
2222

23+
constcreateKeyboardEvent=(type,data)=>{
24+
returnnewKeyboardEvent(type,{
25+
bubbles: true,
26+
cancelable: true,
27+
...data,
28+
});
29+
};
30+
31+
constcreatePointerEvent=(type,data)=>{
32+
constevent=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+
returnevent;
40+
};
41+
2342
describe('Focus event responder',()=>{
2443
letcontainer;
2544

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

160+
describe('onFocusVisibleChange',()=>{
161+
letonFocusVisibleChange,ref;
162+
163+
beforeEach(()=>{
164+
onFocusVisibleChange=jest.fn();
165+
ref=React.createRef();
166+
constelement=(
167+
<FocusonFocusVisibleChange={onFocusVisibleChange}>
168+
<divref={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
constevents=[];

‎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)