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.
Nicolas Gallagher committed
Apr 29, 2019 at 13:52 UTC
2cca18728e7d8a5691df6598629ba21020e22898
4 files changed
+202
-20
packages/react-events/docs/Focus.md
+27
-12
@@ -1,21 +1,31 @@
1
# Focus
2
3
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.
6
7
Focus events do not propagate between `Focus` event responders.
8
9
```js
10
// 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
+};
29
```
30
31
## Types
@@ -23,7 +33,7 @@ const TextField = (props) => (
33
```js
34
type FocusEvent = {
35
target: Element,
26
- type: 'blur' | 'focus' | 'focuschange'
36
+ type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange'
37
}
38
```
39
@@ -43,5 +53,10 @@ Called when the element gains focus.
53
54
### onFocusChange: boolean => void
55
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
57
`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
+103
-4
@@ -19,14 +19,16 @@ type FocusProps = {
19
onBlur: (e: FocusEvent) => void,
20
onFocus: (e: FocusEvent) => void,
21
onFocusChange: boolean => void,
22
+ onFocusVisibleChange: boolean => void,
23
};
24
25
type FocusState = {
25
- isFocused: boolean,
26
focusTarget: null | Element | Document,
27
+ isFocused: boolean,
28
+ isLocalFocusVisible: boolean,
29
};
30
29
-type FocusEventType = 'focus' | 'blur' | 'focuschange';
31
+type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
32
33
type FocusEvent = {|
34
target: Element | Document,
@@ -38,6 +40,21 @@ const targetEventTypes = [
40
{name: 'blur', passive: true, capture: true},
41
];
42
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
+
58
function createFocusEvent(
59
type: FocusEventType,
60
target: Element | Document,
@@ -65,6 +82,13 @@ function dispatchFocusInEvents(
82
const syntheticEvent = createFocusEvent('focuschange', target);
83
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
84
}
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
+ }
92
}
93
94
function dispatchFocusOutEvents(
@@ -84,6 +108,23 @@ function dispatchFocusOutEvents(
108
const syntheticEvent = createFocusEvent('focuschange', target);
109
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
110
}
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
+ }
128
}
129
130
function unmountResponder(
@@ -96,12 +137,16 @@ function unmountResponder(
137
}
138
}
139
140
+let isGlobalFocusVisible = true;
141
+
142
const FocusResponder = {
143
targetEventTypes,
144
+ rootEventTypes,
145
createInitialState(): FocusState {
146
return {
103
- isFocused: false,
147
focusTarget: null,
148
+ isFocused: false,
149
+ isLocalFocusVisible: false,
150
};
151
},
152
stopLocalPropagation: true,
@@ -129,8 +174,9 @@ const FocusResponder = {
174
// Browser focus is not expected to bubble.
175
state.focusTarget = getEventCurrentTarget(event, context);
176
if (state.focusTarget === target) {
132
- dispatchFocusInEvents(context, props, state);
177
state.isFocused = true;
178
+ state.isLocalFocusVisible = isGlobalFocusVisible;
179
+ dispatchFocusInEvents(context, props, state);
180
}
181
}
182
break;
@@ -145,6 +191,59 @@ const FocusResponder = {
191
}
192
}
193
},
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
+ },
247
onUnmount(
248
context: ReactResponderContext,
249
props: FocusProps,
packages/react-events/src/__tests__/Focus-test.internal.js
+68
@@ -20,6 +20,25 @@ const createFocusEvent = type => {
20
return event;
21
};
22
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
+
42
describe('Focus event responder', () => {
43
let container;
44
@@ -138,6 +157,55 @@ describe('Focus event responder', () => {
157
});
158
});
159
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
+
209
describe('nested Focus components', () => {
210
it('do not propagate events by default', () => {
211
const events = [];
packages/react-events/src/__tests__/Press-test.internal.js
+4
-4
@@ -1090,10 +1090,10 @@ describe('Event responder: Press', () => {
1090
ref.current.dispatchEvent(
1091
createPointerEvent('pointermove', coordinatesInside),
1092
);
1093
- ref.current.dispatchEvent(
1093
+ container.dispatchEvent(
1094
createPointerEvent('pointermove', coordinatesOutside),
1095
);
1096
- ref.current.dispatchEvent(
1096
+ container.dispatchEvent(
1097
createPointerEvent('pointerup', coordinatesOutside),
1098
);
1099
jest.runAllTimers();
@@ -1135,13 +1135,13 @@ describe('Event responder: Press', () => {
1135
ref.current.dispatchEvent(
1136
createPointerEvent('pointermove', coordinatesInside),
1137
);
1138
- ref.current.dispatchEvent(
1138
+ container.dispatchEvent(
1139
createPointerEvent('pointermove', coordinatesOutside),
1140
);
1141
jest.runAllTimers();
1142
expect(events).toEqual(['onPressMove']);
1143
events = [];
1144
- ref.current.dispatchEvent(
1144
+ container.dispatchEvent(
1145
createPointerEvent('pointerup', coordinatesOutside),
1146
);
1147
jest.runAllTimers();