[Flare] Add FocusWithin responder (#16152)
FocusWithin is implemented as a separate responder, which keeps both focus responders simple and allows for easier composition of behaviours.
Nicolas Gallagher committed
Jul 17, 2019 at 15:17 UTC
997154bcc7091f93733090497c714e5806424168
3 files changed
+676
-173
packages/react-events/docs/FocusWithin.md
new
+57
@@ -0,0 +1,57 @@
1
+# FocusWithin
2
+
3
+The `FocusWithin` module responds to focus and blur events on its child. Focus events
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 `FocusWithin` event responders.
8
+
9
+```js
10
+// Example
11
+const Button = (props) => {
12
+ const [ focusWithin, updateFocusWithin ] = useState(false);
13
+ const [ focusWithinVisible, updateFocusWithinVisible ] = useState(false);
14
+
15
+ return (
16
+ <FocusWithin
17
+ onFocusWithinChange={updateFocusWithin}
18
+ onFocusWithinVisibleChange={updateFocusWithinVisible}
19
+ >
20
+ <button
21
+ children={props.children}
22
+ style={{
23
+ ...(focusWithin && focusWithinStyles),
24
+ ...(focusWithinVisible && focusWithinVisibleStyles)
25
+ }}
26
+ >
27
+ </FocusWithin>
28
+ );
29
+};
30
+```
31
+
32
+## Types
33
+
34
+```js
35
+type FocusEvent = {
36
+ target: Element,
37
+ type: 'focuswithinchange' | 'focuswithinvisiblechange'
38
+}
39
+```
40
+
41
+## Props
42
+
43
+### disabled: boolean = false
44
+
45
+Disables all `FocusWithin` events.
46
+
47
+### onFocusWithinChange: boolean => void
48
+
49
+Called once the element or a descendant receives focus, and once focus moves
50
+outside of the element.
51
+
52
+### onFocusWithinVisibleChange: boolean => void
53
+
54
+Called once the element or a descendant is focused following keyboard
55
+navigation, and once focus moves outside of the element. This can be used to
56
+display focus styles only when the keyboard is being used to focus within the
57
+element's subtree.
packages/react-events/src/dom/Focus.js
+301
-117
@@ -17,6 +17,24 @@ import type {
17
import React from 'react';
18
import {DiscreteEvent} from 'shared/ReactTypes';
19
20
+/**
21
+ * Types
22
+ */
23
+
24
+type FocusEvent = {|
25
+ target: Element | Document,
26
+ type: FocusEventType | FocusWithinEventType,
27
+ pointerType: PointerType,
28
+ timeStamp: number,
29
+|};
30
+
31
+type FocusState = {
32
+ focusTarget: null | Element | Document,
33
+ isFocused: boolean,
34
+ isFocusVisible: boolean,
35
+ pointerType: PointerType,
36
+};
37
+
38
type FocusProps = {
39
disabled: boolean,
40
onBlur: (e: FocusEvent) => void,
@@ -25,21 +43,21 @@ type FocusProps = {
43
onFocusVisibleChange: boolean => void,
44
};
45
28
-type FocusState = {
29
- focusTarget: null | Element | Document,
30
- isFocused: boolean,
31
- isLocalFocusVisible: boolean,
32
- pointerType: PointerType,
46
+type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
47
+
48
+type FocusWithinProps = {
49
+ disabled: boolean,
50
+ onFocusWithinChange: boolean => void,
51
+ onFocusWithinVisibleChange: boolean => void,
52
};
53
35
-type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
54
+type FocusWithinEventType = 'focuswithinvisiblechange' | 'focuswithinchange';
55
37
-type FocusEvent = {|
38
- target: Element | Document,
39
- type: FocusEventType,
40
- pointerType: PointerType,
41
- timeStamp: number,
42
-|};
56
+/**
57
+ * Shared between Focus and FocusWithin
58
+ */
59
+
60
+let isGlobalFocusVisible = true;
61
62
const isMac =
63
typeof window !== 'undefined' && window.navigator != null
@@ -70,7 +88,7 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
88
89
function createFocusEvent(
90
context: ReactDOMResponderContext,
73
- type: FocusEventType,
91
+ type: FocusEventType | FocusWithinEventType,
92
target: Element | Document,
93
pointerType: PointerType,
94
): FocusEvent {
@@ -82,7 +100,90 @@ function createFocusEvent(
100
};
101
}
102
85
-function dispatchFocusInEvents(
103
+function handleRootPointerEvent(
104
+ event: ReactDOMResponderEvent,
105
+ context: ReactDOMResponderContext,
106
+ state: FocusState,
107
+ callback: boolean => void,
108
+): void {
109
+ const {type, target} = event;
110
+ // Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
111
+ // element when the window blurs.
112
+ if (type === 'mousemove' && target.nodeName === 'HTML') {
113
+ return;
114
+ }
115
+
116
+ isGlobalFocusVisible = false;
117
+
118
+ // Focus should stop being visible if a pointer is used on the element
119
+ // after it was focused using a keyboard.
120
+ const focusTarget = state.focusTarget;
121
+ if (
122
+ focusTarget !== null &&
123
+ context.isTargetWithinNode(event.target, focusTarget) &&
124
+ (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
125
+ ) {
126
+ callback(false);
127
+ }
128
+}
129
+
130
+function handleRootEvent(
131
+ event: ReactDOMResponderEvent,
132
+ context: ReactDOMResponderContext,
133
+ state: FocusState,
134
+ callback: boolean => void,
135
+): void {
136
+ const {type} = event;
137
+
138
+ switch (type) {
139
+ case 'mousemove':
140
+ case 'mousedown':
141
+ case 'mouseup': {
142
+ state.pointerType = 'mouse';
143
+ handleRootPointerEvent(event, context, state, callback);
144
+ break;
145
+ }
146
+ case 'pointermove':
147
+ case 'pointerdown':
148
+ case 'pointerup': {
149
+ // $FlowFixMe: Flow doesn't know about PointerEvents
150
+ const nativeEvent = ((event.nativeEvent: any): PointerEvent);
151
+ state.pointerType = nativeEvent.pointerType;
152
+ handleRootPointerEvent(event, context, state, callback);
153
+ break;
154
+ }
155
+ case 'touchmove':
156
+ case 'touchstart':
157
+ case 'touchend': {
158
+ state.pointerType = 'touch';
159
+ handleRootPointerEvent(event, context, state, callback);
160
+ break;
161
+ }
162
+
163
+ case 'keydown':
164
+ case 'keyup': {
165
+ const nativeEvent = event.nativeEvent;
166
+ if (
167
+ nativeEvent.key === 'Tab' &&
168
+ !(
169
+ nativeEvent.metaKey ||
170
+ (!isMac && nativeEvent.altKey) ||
171
+ nativeEvent.ctrlKey
172
+ )
173
+ ) {
174
+ state.pointerType = 'keyboard';
175
+ isGlobalFocusVisible = true;
176
+ }
177
+ break;
178
+ }
179
+ }
180
+}
181
+
182
+/**
183
+ * Focus Responder
184
+ */
185
+
186
+function dispatchFocusEvents(
187
context: ReactDOMResponderContext,
188
props: FocusProps,
189
state: FocusState,
@@ -110,21 +211,12 @@ function dispatchFocusInEvents(
211
);
212
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
213
}
113
- if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
114
- const listener = () => {
115
- props.onFocusVisibleChange(true);
116
- };
117
- const syntheticEvent = createFocusEvent(
118
- context,
119
- 'focusvisiblechange',
120
- target,
121
- pointerType,
122
- );
123
- context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
214
+ if (state.isFocusVisible) {
215
+ dispatchFocusVisibleChangeEvent(context, props, state, true);
216
}
217
}
218
127
-function dispatchFocusOutEvents(
219
+function dispatchBlurEvents(
220
context: ReactDOMResponderContext,
221
props: FocusProps,
222
state: FocusState,
@@ -152,19 +244,22 @@ function dispatchFocusOutEvents(
244
);
245
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
246
}
155
- dispatchFocusVisibleOutEvent(context, props, state);
247
+ if (state.isFocusVisible) {
248
+ dispatchFocusVisibleChangeEvent(context, props, state, false);
249
+ }
250
}
251
158
-function dispatchFocusVisibleOutEvent(
252
+function dispatchFocusVisibleChangeEvent(
253
context: ReactDOMResponderContext,
254
props: FocusProps,
255
state: FocusState,
256
+ value: boolean,
257
) {
258
const pointerType = state.pointerType;
259
const target = ((state.focusTarget: any): Element | Document);
165
- if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
260
+ if (props.onFocusVisibleChange) {
261
const listener = () => {
167
- props.onFocusVisibleChange(false);
262
+ props.onFocusVisibleChange(value);
263
};
264
const syntheticEvent = createFocusEvent(
265
context,
@@ -173,49 +268,19 @@ function dispatchFocusVisibleOutEvent(
268
pointerType,
269
);
270
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
176
- state.isLocalFocusVisible = false;
271
}
272
}
273
180
-function unmountResponder(
274
+function unmountFocusResponder(
275
context: ReactDOMResponderContext,
276
props: FocusProps,
277
state: FocusState,
184
-): void {
278
+) {
279
if (state.isFocused) {
186
- dispatchFocusOutEvents(context, props, state);
187
- }
188
-}
189
-
190
-function handleRootPointerEvent(
191
- event: ReactDOMResponderEvent,
192
- context: ReactDOMResponderContext,
193
- props: FocusProps,
194
- state: FocusState,
195
-): void {
196
- const {type, target} = event;
197
- // Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
198
- // element when the window blurs.
199
- if (type === 'mousemove' && target.nodeName === 'HTML') {
200
- return;
201
- }
202
-
203
- isGlobalFocusVisible = false;
204
-
205
- // Focus should stop being visible if a pointer is used on the element
206
- // after it was focused using a keyboard.
207
- const focusTarget = state.focusTarget;
208
- if (
209
- focusTarget !== null &&
210
- context.isTargetWithinNode(event.target, focusTarget) &&
211
- (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
212
- ) {
213
- dispatchFocusVisibleOutEvent(context, props, state);
280
+ dispatchBlurEvents(context, props, state);
281
}
282
}
283
217
-let isGlobalFocusVisible = true;
218
-
284
const FocusResponder: ReactDOMEventResponder = {
285
displayName: 'Focus',
286
targetEventTypes,
@@ -224,7 +289,7 @@ const FocusResponder: ReactDOMEventResponder = {
289
return {
290
focusTarget: null,
291
isFocused: false,
227
- isLocalFocusVisible: false,
292
+ isFocusVisible: false,
293
pointerType: '',
294
};
295
},
@@ -238,7 +303,7 @@ const FocusResponder: ReactDOMEventResponder = {
303
304
if (props.disabled) {
305
if (state.isFocused) {
241
- dispatchFocusOutEvents(context, props, state);
306
+ dispatchBlurEvents(context, props, state);
307
state.isFocused = false;
308
state.focusTarget = null;
309
}
@@ -247,23 +312,21 @@ const FocusResponder: ReactDOMEventResponder = {
312
313
switch (type) {
314
case 'focus': {
250
- if (!state.isFocused) {
251
- // Limit focus events to the direct child of the event component.
252
- // Browser focus is not expected to bubble.
253
- state.focusTarget = event.responderTarget;
254
- if (state.focusTarget === target) {
255
- state.isFocused = true;
256
- state.isLocalFocusVisible = isGlobalFocusVisible;
257
- dispatchFocusInEvents(context, props, state);
258
- }
315
+ state.focusTarget = event.responderTarget;
316
+ // Limit focus events to the direct child of the event component.
317
+ // Browser focus is not expected to bubble.
318
+ if (!state.isFocused && state.focusTarget === target) {
319
+ state.isFocused = true;
320
+ state.isFocusVisible = isGlobalFocusVisible;
321
+ dispatchFocusEvents(context, props, state);
322
}
323
break;
324
}
325
case 'blur': {
326
if (state.isFocused) {
264
- dispatchFocusOutEvents(context, props, state);
327
+ dispatchBlurEvents(context, props, state);
328
+ state.isFocusVisible = isGlobalFocusVisible;
329
state.isFocused = false;
266
- state.focusTarget = null;
330
}
331
break;
332
}
@@ -275,69 +338,190 @@ const FocusResponder: ReactDOMEventResponder = {
338
props: FocusProps,
339
state: FocusState,
340
): void {
278
- const {type} = event;
279
-
280
- switch (type) {
281
- case 'mousemove':
282
- case 'mousedown':
283
- case 'mouseup': {
284
- state.pointerType = 'mouse';
285
- handleRootPointerEvent(event, context, props, state);
286
- break;
341
+ handleRootEvent(event, context, state, isFocusVisible => {
342
+ if (state.isFocusVisible !== isFocusVisible) {
343
+ state.isFocusVisible = isFocusVisible;
344
+ dispatchFocusVisibleChangeEvent(context, props, state, isFocusVisible);
345
}
288
- case 'pointermove':
289
- case 'pointerdown':
290
- case 'pointerup': {
291
- // $FlowFixMe: Flow doesn't know about PointerEvents
292
- const nativeEvent = ((event.nativeEvent: any): PointerEvent);
293
- state.pointerType = nativeEvent.pointerType;
294
- handleRootPointerEvent(event, context, props, state);
295
- break;
346
+ });
347
+ },
348
+ onUnmount(
349
+ context: ReactDOMResponderContext,
350
+ props: FocusProps,
351
+ state: FocusState,
352
+ ) {
353
+ unmountFocusResponder(context, props, state);
354
+ },
355
+ onOwnershipChange(
356
+ context: ReactDOMResponderContext,
357
+ props: FocusProps,
358
+ state: FocusState,
359
+ ) {
360
+ unmountFocusResponder(context, props, state);
361
+ },
362
+};
363
+
364
+export const Focus = React.unstable_createEvent(FocusResponder);
365
+
366
+export function useFocus(props: FocusProps): void {
367
+ React.unstable_useEvent(Focus, props);
368
+}
369
+
370
+/**
371
+ * FocusWithin Responder
372
+ */
373
+
374
+function dispatchFocusWithinChangeEvent(
375
+ context: ReactDOMResponderContext,
376
+ props: FocusWithinProps,
377
+ state: FocusState,
378
+ value: boolean,
379
+) {
380
+ const pointerType = state.pointerType;
381
+ const target = ((state.focusTarget: any): Element | Document);
382
+ if (props.onFocusWithinChange) {
383
+ const listener = function() {
384
+ props.onFocusWithinChange(value);
385
+ };
386
+ const syntheticEvent = createFocusEvent(
387
+ context,
388
+ 'focuswithinchange',
389
+ target,
390
+ pointerType,
391
+ );
392
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
393
+ }
394
+ if (state.isFocusVisible) {
395
+ dispatchFocusWithinVisibleChangeEvent(context, props, state, value);
396
+ }
397
+}
398
+
399
+function dispatchFocusWithinVisibleChangeEvent(
400
+ context: ReactDOMResponderContext,
401
+ props: FocusWithinProps,
402
+ state: FocusState,
403
+ value: boolean,
404
+) {
405
+ const pointerType = state.pointerType;
406
+ const target = ((state.focusTarget: any): Element | Document);
407
+ if (props.onFocusWithinVisibleChange) {
408
+ const listener = function() {
409
+ props.onFocusWithinVisibleChange(value);
410
+ };
411
+ const syntheticEvent = createFocusEvent(
412
+ context,
413
+ 'focuswithinvisiblechange',
414
+ target,
415
+ pointerType,
416
+ );
417
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
418
+ }
419
+}
420
+
421
+function unmountFocusWithinResponder(
422
+ context: ReactDOMResponderContext,
423
+ props: FocusWithinProps,
424
+ state: FocusState,
425
+) {
426
+ if (state.isFocused) {
427
+ dispatchFocusWithinChangeEvent(context, props, state, false);
428
+ }
429
+}
430
+
431
+const FocusWithinResponder: ReactDOMEventResponder = {
432
+ displayName: 'FocusWithin',
433
+ targetEventTypes,
434
+ rootEventTypes,
435
+ getInitialState(): FocusState {
436
+ return {
437
+ focusTarget: null,
438
+ isFocused: false,
439
+ isFocusVisible: false,
440
+ pointerType: '',
441
+ };
442
+ },
443
+ onEvent(
444
+ event: ReactDOMResponderEvent,
445
+ context: ReactDOMResponderContext,
446
+ props: FocusWithinProps,
447
+ state: FocusState,
448
+ ): void {
449
+ const {nativeEvent, type} = event;
450
+ const relatedTarget = (nativeEvent: any).relatedTarget;
451
+
452
+ if (props.disabled) {
453
+ if (state.isFocused) {
454
+ dispatchFocusWithinChangeEvent(context, props, state, false);
455
+ state.isFocused = false;
456
+ state.focusTarget = null;
457
}
297
- case 'touchmove':
298
- case 'touchstart':
299
- case 'touchend': {
300
- state.pointerType = 'touch';
301
- handleRootPointerEvent(event, context, props, state);
458
+ return;
459
+ }
460
+
461
+ switch (type) {
462
+ case 'focus': {
463
+ state.focusTarget = event.responderTarget;
464
+ // Limit focus events to the direct child of the event component.
465
+ // Browser focus is not expected to bubble.
466
+ if (!state.isFocused) {
467
+ state.isFocused = true;
468
+ state.isFocusVisible = isGlobalFocusVisible;
469
+ dispatchFocusWithinChangeEvent(context, props, state, true);
470
+ }
471
+ if (!state.isFocusVisible && isGlobalFocusVisible) {
472
+ state.isFocusVisible = isGlobalFocusVisible;
473
+ dispatchFocusWithinVisibleChangeEvent(context, props, state, true);
474
+ }
475
break;
476
}
304
-
305
- case 'keydown':
306
- case 'keyup': {
307
- const nativeEvent = event.nativeEvent;
477
+ case 'blur': {
478
if (
309
- nativeEvent.key === 'Tab' &&
310
- !(
311
- nativeEvent.metaKey ||
312
- (!isMac && nativeEvent.altKey) ||
313
- nativeEvent.ctrlKey
314
- )
479
+ state.isFocused &&
480
+ !context.isTargetWithinEventResponderScope(relatedTarget)
481
) {
316
- state.pointerType = 'keyboard';
317
- isGlobalFocusVisible = true;
482
+ dispatchFocusWithinChangeEvent(context, props, state, false);
483
+ state.isFocused = false;
484
}
485
break;
486
}
487
}
488
},
489
+ onRootEvent(
490
+ event: ReactDOMResponderEvent,
491
+ context: ReactDOMResponderContext,
492
+ props: FocusWithinProps,
493
+ state: FocusState,
494
+ ): void {
495
+ handleRootEvent(event, context, state, isFocusVisible => {
496
+ if (state.isFocusVisible !== isFocusVisible) {
497
+ state.isFocusVisible = isFocusVisible;
498
+ dispatchFocusWithinVisibleChangeEvent(
499
+ context,
500
+ props,
501
+ state,
502
+ isFocusVisible,
503
+ );
504
+ }
505
+ });
506
+ },
507
onUnmount(
508
context: ReactDOMResponderContext,
325
- props: FocusProps,
509
+ props: FocusWithinProps,
510
state: FocusState,
511
) {
328
- unmountResponder(context, props, state);
512
+ unmountFocusWithinResponder(context, props, state);
513
},
514
onOwnershipChange(
515
context: ReactDOMResponderContext,
332
- props: FocusProps,
516
+ props: FocusWithinProps,
517
state: FocusState,
518
) {
335
- unmountResponder(context, props, state);
519
+ unmountFocusWithinResponder(context, props, state);
520
},
521
};
522
339
-export const Focus = React.unstable_createEvent(FocusResponder);
523
+export const FocusWithin = React.unstable_createEvent(FocusWithinResponder);
524
341
-export function useFocus(props: FocusProps): void {
342
- React.unstable_useEvent(Focus, props);
525
+export function useFocusWithin(props: FocusWithinProps): void {
526
+ React.unstable_useEvent(FocusWithin, props);
527
}
packages/react-events/src/dom/__tests__/Focus-test.internal.js
+318
-56
@@ -13,10 +13,16 @@ let React;
13
let ReactFeatureFlags;
14
let ReactDOM;
15
let Focus;
16
+let FocusWithin;
17
17
-const createFocusEvent = type => {
18
- const event = document.createEvent('Event');
19
- event.initEvent(type, true, true);
18
+const createEvent = (type, data) => {
19
+ const event = document.createEvent('CustomEvent');
20
+ event.initCustomEvent(type, true, true);
21
+ if (data != null) {
22
+ Object.entries(data).forEach(([key, value]) => {
23
+ event[key] = value;
24
+ });
25
+ }
26
return event;
27
};
28
@@ -28,23 +34,13 @@ const createKeyboardEvent = (type, data) => {
34
});
35
};
36
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
-
37
const modulesInit = () => {
38
ReactFeatureFlags = require('shared/ReactFeatureFlags');
39
ReactFeatureFlags.enableFlareAPI = true;
40
React = require('react');
41
ReactDOM = require('react-dom');
42
Focus = require('react-events/focus').Focus;
43
+ FocusWithin = require('react-events/focus').FocusWithin;
44
};
45
46
describe('Focus event responder', () => {
@@ -80,8 +76,8 @@ describe('Focus event responder', () => {
76
});
77
78
it('prevents custom events being dispatched', () => {
83
- ref.current.dispatchEvent(createFocusEvent('focus'));
84
- ref.current.dispatchEvent(createFocusEvent('blur'));
79
+ ref.current.dispatchEvent(createEvent('focus'));
80
+ ref.current.dispatchEvent(createEvent('blur'));
81
expect(onFocus).not.toBeCalled();
82
expect(onBlur).not.toBeCalled();
83
});
@@ -102,8 +98,8 @@ describe('Focus event responder', () => {
98
});
99
100
it('is called after "blur" event', () => {
105
- ref.current.dispatchEvent(createFocusEvent('focus'));
106
- ref.current.dispatchEvent(createFocusEvent('blur'));
101
+ ref.current.dispatchEvent(createEvent('focus'));
102
+ ref.current.dispatchEvent(createEvent('blur'));
103
expect(onBlur).toHaveBeenCalledTimes(1);
104
});
105
});
@@ -128,50 +124,50 @@ describe('Focus event responder', () => {
124
beforeEach(componentInit);
125
126
it('is called after "focus" event', () => {
131
- ref.current.dispatchEvent(createFocusEvent('focus'));
127
+ ref.current.dispatchEvent(createEvent('focus'));
128
expect(onFocus).toHaveBeenCalledTimes(1);
129
});
130
131
it('is not called if descendants of target receive focus', () => {
132
const target = innerRef.current;
137
- target.dispatchEvent(createFocusEvent('focus'));
133
+ target.dispatchEvent(createEvent('focus'));
134
expect(onFocus).not.toBeCalled();
135
});
136
137
it('is called with the correct pointerType using pointer events', () => {
138
// Pointer mouse
139
ref.current.dispatchEvent(
144
- createPointerEvent('pointerdown', {
140
+ createEvent('pointerdown', {
141
pointerType: 'mouse',
142
}),
143
);
148
- ref.current.dispatchEvent(createFocusEvent('focus'));
144
+ ref.current.dispatchEvent(createEvent('focus'));
145
expect(onFocus).toHaveBeenCalledTimes(1);
146
expect(onFocus).toHaveBeenCalledWith(
147
expect.objectContaining({pointerType: 'mouse'}),
148
);
153
- ref.current.dispatchEvent(createFocusEvent('blur'));
149
+ ref.current.dispatchEvent(createEvent('blur'));
150
151
// Pointer touch
152
ref.current.dispatchEvent(
157
- createPointerEvent('pointerdown', {
153
+ createEvent('pointerdown', {
154
pointerType: 'touch',
155
}),
156
);
161
- ref.current.dispatchEvent(createFocusEvent('focus'));
157
+ ref.current.dispatchEvent(createEvent('focus'));
158
expect(onFocus).toHaveBeenCalledTimes(2);
159
expect(onFocus).toHaveBeenCalledWith(
160
expect.objectContaining({pointerType: 'touch'}),
161
);
166
- ref.current.dispatchEvent(createFocusEvent('blur'));
162
+ ref.current.dispatchEvent(createEvent('blur'));
163
164
// Pointer pen
165
ref.current.dispatchEvent(
170
- createPointerEvent('pointerdown', {
166
+ createEvent('pointerdown', {
167
pointerType: 'pen',
168
}),
169
);
174
- ref.current.dispatchEvent(createFocusEvent('focus'));
170
+ ref.current.dispatchEvent(createEvent('focus'));
171
expect(onFocus).toHaveBeenCalledTimes(3);
172
expect(onFocus).toHaveBeenCalledWith(
173
expect.objectContaining({pointerType: 'pen'}),
@@ -180,17 +176,17 @@ describe('Focus event responder', () => {
176
177
it('is called with the correct pointerType without pointer events', () => {
178
// Mouse
183
- ref.current.dispatchEvent(createPointerEvent('mousedown'));
184
- ref.current.dispatchEvent(createFocusEvent('focus'));
179
+ ref.current.dispatchEvent(createEvent('mousedown'));
180
+ ref.current.dispatchEvent(createEvent('focus'));
181
expect(onFocus).toHaveBeenCalledTimes(1);
182
expect(onFocus).toHaveBeenCalledWith(
183
expect.objectContaining({pointerType: 'mouse'}),
184
);
189
- ref.current.dispatchEvent(createFocusEvent('blur'));
185
+ ref.current.dispatchEvent(createEvent('blur'));
186
187
// Touch
192
- ref.current.dispatchEvent(createPointerEvent('touchstart'));
193
- ref.current.dispatchEvent(createFocusEvent('focus'));
188
+ ref.current.dispatchEvent(createEvent('touchstart'));
189
+ ref.current.dispatchEvent(createEvent('focus'));
190
expect(onFocus).toHaveBeenCalledTimes(2);
191
expect(onFocus).toHaveBeenCalledWith(
192
expect.objectContaining({pointerType: 'touch'}),
@@ -200,11 +196,11 @@ describe('Focus event responder', () => {
196
it('is called with the correct pointerType using a keyboard', () => {
197
// Keyboard tab
198
ref.current.dispatchEvent(
203
- createPointerEvent('keydown', {
199
+ createEvent('keydown', {
200
key: 'Tab',
201
}),
202
);
207
- ref.current.dispatchEvent(createFocusEvent('focus'));
203
+ ref.current.dispatchEvent(createEvent('focus'));
204
expect(onFocus).toHaveBeenCalledTimes(1);
205
expect(onFocus).toHaveBeenCalledWith(
206
expect.objectContaining({pointerType: 'keyboard'}),
@@ -219,12 +215,12 @@ describe('Focus event responder', () => {
215
componentInit();
216
217
ref.current.dispatchEvent(
222
- createPointerEvent('keydown', {
218
+ createEvent('keydown', {
219
key: 'Tab',
220
altKey: true,
221
}),
222
);
227
- ref.current.dispatchEvent(createFocusEvent('focus'));
223
+ ref.current.dispatchEvent(createEvent('focus'));
224
expect(onFocus).toHaveBeenCalledTimes(1);
225
expect(onFocus).toHaveBeenCalledWith(
226
expect.objectContaining({
@@ -237,38 +233,51 @@ describe('Focus event responder', () => {
233
});
234
235
describe('onFocusChange', () => {
240
- let onFocusChange, ref;
236
+ let onFocusChange, ref, innerRef;
237
238
beforeEach(() => {
239
onFocusChange = jest.fn();
240
ref = React.createRef();
241
+ innerRef = React.createRef();
242
const element = (
243
<Focus onFocusChange={onFocusChange}>
247
- <div ref={ref} />
244
+ <div ref={ref}>
245
+ <div ref={innerRef} />
246
+ </div>
247
</Focus>
248
);
249
ReactDOM.render(element, container);
250
});
251
252
it('is called after "blur" and "focus" events', () => {
254
- ref.current.dispatchEvent(createFocusEvent('focus'));
253
+ ref.current.dispatchEvent(createEvent('focus'));
254
expect(onFocusChange).toHaveBeenCalledTimes(1);
255
expect(onFocusChange).toHaveBeenCalledWith(true);
257
- ref.current.dispatchEvent(createFocusEvent('blur'));
256
+ ref.current.dispatchEvent(createEvent('blur'));
257
expect(onFocusChange).toHaveBeenCalledTimes(2);
258
expect(onFocusChange).toHaveBeenCalledWith(false);
259
});
260
+
261
+ it('is not called after "blur" and "focus" events on descendants', () => {
262
+ innerRef.current.dispatchEvent(createEvent('focus'));
263
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
264
+ innerRef.current.dispatchEvent(createEvent('blur'));
265
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
266
+ });
267
});
268
269
describe('onFocusVisibleChange', () => {
264
- let onFocusVisibleChange, ref;
270
+ let onFocusVisibleChange, ref, innerRef;
271
272
beforeEach(() => {
273
onFocusVisibleChange = jest.fn();
274
ref = React.createRef();
275
+ innerRef = React.createRef();
276
const element = (
277
<Focus onFocusVisibleChange={onFocusVisibleChange}>
271
- <div ref={ref} />
278
+ <div ref={ref}>
279
+ <div ref={innerRef} />
280
+ </div>
281
</Focus>
282
);
283
ReactDOM.render(element, container);
@@ -277,10 +286,12 @@ describe('Focus event responder', () => {
286
it('is called after "focus" and "blur" if keyboard navigation is active', () => {
287
// use keyboard first
288
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
280
- ref.current.dispatchEvent(createFocusEvent('focus'));
289
+ ref.current.dispatchEvent(createEvent('focus'));
290
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
291
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
283
- ref.current.dispatchEvent(createFocusEvent('blur'));
292
+ ref.current.dispatchEvent(
293
+ createEvent('blur', {relatedTarget: container}),
294
+ );
295
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
296
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
297
});
@@ -288,23 +299,37 @@ describe('Focus event responder', () => {
299
it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
300
// use keyboard first
301
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
291
- ref.current.dispatchEvent(createFocusEvent('focus'));
302
+ ref.current.dispatchEvent(createEvent('focus'));
303
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
304
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
305
// then use pointer on the target, focus should no longer be visible
295
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
306
+ ref.current.dispatchEvent(createEvent('pointerdown'));
307
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
308
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
309
// onFocusVisibleChange should not be called again
299
- ref.current.dispatchEvent(createFocusEvent('blur'));
310
+ ref.current.dispatchEvent(
311
+ createEvent('blur', {relatedTarget: container}),
312
+ );
313
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
314
});
315
316
it('is not called after "focus" and "blur" events without keyboard', () => {
304
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
305
- ref.current.dispatchEvent(createFocusEvent('focus'));
306
- container.dispatchEvent(createPointerEvent('pointerdown'));
307
- ref.current.dispatchEvent(createFocusEvent('blur'));
317
+ ref.current.dispatchEvent(createEvent('pointerdown'));
318
+ ref.current.dispatchEvent(createEvent('focus'));
319
+ container.dispatchEvent(createEvent('pointerdown'));
320
+ ref.current.dispatchEvent(
321
+ createEvent('blur', {relatedTarget: container}),
322
+ );
323
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
324
+ });
325
+
326
+ it('is not called after "blur" and "focus" events on descendants', () => {
327
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
328
+ innerRef.current.dispatchEvent(createEvent('focus'));
329
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
330
+ innerRef.current.dispatchEvent(
331
+ createEvent('blur', {relatedTarget: container}),
332
+ );
333
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
334
});
335
});
@@ -336,10 +361,10 @@ describe('Focus event responder', () => {
361
362
ReactDOM.render(element, container);
363
339
- outerRef.current.dispatchEvent(createFocusEvent('focus'));
340
- outerRef.current.dispatchEvent(createFocusEvent('blur'));
341
- innerRef.current.dispatchEvent(createFocusEvent('focus'));
342
- innerRef.current.dispatchEvent(createFocusEvent('blur'));
364
+ outerRef.current.dispatchEvent(createEvent('focus'));
365
+ outerRef.current.dispatchEvent(createEvent('blur'));
366
+ innerRef.current.dispatchEvent(createEvent('focus'));
367
+ innerRef.current.dispatchEvent(createEvent('blur'));
368
expect(events).toEqual([
369
'outer: onFocus',
370
'outer: onFocusChange',
@@ -357,3 +382,240 @@ describe('Focus event responder', () => {
382
expect(Focus.responder.displayName).toBe('Focus');
383
});
384
});
385
+
386
+describe('FocusWithin event responder', () => {
387
+ let container;
388
+
389
+ beforeEach(() => {
390
+ jest.resetModules();
391
+ modulesInit();
392
+
393
+ container = document.createElement('div');
394
+ document.body.appendChild(container);
395
+ });
396
+
397
+ afterEach(() => {
398
+ ReactDOM.render(null, container);
399
+ document.body.removeChild(container);
400
+ container = null;
401
+ });
402
+
403
+ describe('disabled', () => {
404
+ let onFocusWithinChange, onFocusWithinVisibleChange, ref;
405
+
406
+ beforeEach(() => {
407
+ onFocusWithinChange = jest.fn();
408
+ onFocusWithinVisibleChange = jest.fn();
409
+ ref = React.createRef();
410
+ const element = (
411
+ <FocusWithin
412
+ disabled={true}
413
+ onFocusWithinChange={onFocusWithinChange}
414
+ onFocusWithinVisibleChange={onFocusWithinVisibleChange}>
415
+ <div ref={ref} />
416
+ </FocusWithin>
417
+ );
418
+ ReactDOM.render(element, container);
419
+ });
420
+
421
+ it('prevents custom events being dispatched', () => {
422
+ ref.current.dispatchEvent(createEvent('focus'));
423
+ ref.current.dispatchEvent(createEvent('blur'));
424
+ expect(onFocusWithinChange).not.toBeCalled();
425
+ expect(onFocusWithinVisibleChange).not.toBeCalled();
426
+ });
427
+ });
428
+
429
+ describe('onFocusWithinChange', () => {
430
+ let onFocusWithinChange, ref, innerRef, innerRef2;
431
+
432
+ beforeEach(() => {
433
+ onFocusWithinChange = jest.fn();
434
+ ref = React.createRef();
435
+ innerRef = React.createRef();
436
+ innerRef2 = React.createRef();
437
+ const element = (
438
+ <FocusWithin onFocusWithinChange={onFocusWithinChange}>
439
+ <div ref={ref}>
440
+ <div ref={innerRef} />
441
+ <div ref={innerRef2} />
442
+ </div>
443
+ </FocusWithin>
444
+ );
445
+ ReactDOM.render(element, container);
446
+ });
447
+
448
+ it('is called after "blur" and "focus" events on focus target', () => {
449
+ ref.current.dispatchEvent(createEvent('focus'));
450
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
451
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
452
+ ref.current.dispatchEvent(
453
+ createEvent('blur', {relatedTarget: container}),
454
+ );
455
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
456
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
457
+ });
458
+
459
+ it('is called after "blur" and "focus" events on descendants', () => {
460
+ innerRef.current.dispatchEvent(createEvent('focus'));
461
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
462
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
463
+ innerRef.current.dispatchEvent(
464
+ createEvent('blur', {relatedTarget: container}),
465
+ );
466
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
467
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
468
+ });
469
+
470
+ it('is only called once when focus moves within and outside the subtree', () => {
471
+ // focus shifts into subtree
472
+ innerRef.current.dispatchEvent(createEvent('focus'));
473
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
474
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
475
+ // focus moves around subtree
476
+ innerRef.current.dispatchEvent(
477
+ createEvent('blur', {relatedTarget: innerRef2.current}),
478
+ );
479
+ innerRef2.current.dispatchEvent(createEvent('focus'));
480
+ innerRef2.current.dispatchEvent(
481
+ createEvent('blur', {relatedTarget: ref.current}),
482
+ );
483
+ ref.current.dispatchEvent(createEvent('focus'));
484
+ ref.current.dispatchEvent(
485
+ createEvent('blur', {relatedTarget: innerRef.current}),
486
+ );
487
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
488
+ // focus shifts outside subtree
489
+ innerRef.current.dispatchEvent(
490
+ createEvent('blur', {relatedTarget: container}),
491
+ );
492
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
493
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
494
+ });
495
+ });
496
+
497
+ describe('onFocusWithinVisibleChange', () => {
498
+ let onFocusWithinVisibleChange, ref, innerRef, innerRef2;
499
+
500
+ beforeEach(() => {
501
+ onFocusWithinVisibleChange = jest.fn();
502
+ ref = React.createRef();
503
+ innerRef = React.createRef();
504
+ innerRef2 = React.createRef();
505
+ const element = (
506
+ <FocusWithin onFocusWithinVisibleChange={onFocusWithinVisibleChange}>
507
+ <div ref={ref}>
508
+ <div ref={innerRef} />
509
+ <div ref={innerRef2} />
510
+ </div>
511
+ </FocusWithin>
512
+ );
513
+ ReactDOM.render(element, container);
514
+ });
515
+
516
+ it('is called after "focus" and "blur" on focus target if keyboard was used', () => {
517
+ // use keyboard first
518
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
519
+ ref.current.dispatchEvent(createEvent('focus'));
520
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
521
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
522
+ ref.current.dispatchEvent(
523
+ createEvent('blur', {relatedTarget: container}),
524
+ );
525
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
526
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
527
+ });
528
+
529
+ it('is called after "focus" and "blur" on descendants if keyboard was used', () => {
530
+ // use keyboard first
531
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
532
+ innerRef.current.dispatchEvent(createEvent('focus'));
533
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
534
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
535
+ innerRef.current.dispatchEvent(
536
+ createEvent('blur', {relatedTarget: container}),
537
+ );
538
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
539
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
540
+ });
541
+
542
+ it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
543
+ // use keyboard first
544
+ ref.current.dispatchEvent(createEvent('focus'));
545
+ ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
546
+ ref.current.dispatchEvent(
547
+ createEvent('blur', {relatedTarget: innerRef.current}),
548
+ );
549
+ innerRef.current.dispatchEvent(createEvent('focus'));
550
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
551
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
552
+ // then use pointer on the next target, focus should no longer be visible
553
+ innerRef2.current.dispatchEvent(createEvent('pointerdown'));
554
+ innerRef.current.dispatchEvent(
555
+ createEvent('blur', {relatedTarget: innerRef2.current}),
556
+ );
557
+ innerRef2.current.dispatchEvent(createEvent('focus'));
558
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
559
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
560
+ // then use keyboard again
561
+ innerRef2.current.dispatchEvent(
562
+ createKeyboardEvent('keydown', {key: 'Tab', shiftKey: true}),
563
+ );
564
+ innerRef2.current.dispatchEvent(
565
+ createEvent('blur', {relatedTarget: innerRef.current}),
566
+ );
567
+ innerRef.current.dispatchEvent(createEvent('focus'));
568
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(3);
569
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
570
+ // then use pointer on the target, focus should no longer be visible
571
+ innerRef.current.dispatchEvent(createEvent('pointerdown'));
572
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4);
573
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
574
+ // onFocusVisibleChange should not be called again
575
+ innerRef.current.dispatchEvent(
576
+ createEvent('blur', {relatedTarget: container}),
577
+ );
578
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4);
579
+ });
580
+
581
+ it('is not called after "focus" and "blur" events without keyboard', () => {
582
+ innerRef.current.dispatchEvent(createEvent('pointerdown'));
583
+ innerRef.current.dispatchEvent(createEvent('focus'));
584
+ container.dispatchEvent(createEvent('pointerdown'));
585
+ innerRef.current.dispatchEvent(
586
+ createEvent('blur', {relatedTarget: container}),
587
+ );
588
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(0);
589
+ });
590
+
591
+ it('is only called once when focus moves within and outside the subtree', () => {
592
+ // focus shifts into subtree
593
+ innerRef.current.dispatchEvent(createEvent('focus'));
594
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
595
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
596
+ // focus moves around subtree
597
+ innerRef.current.dispatchEvent(
598
+ createEvent('blur', {relatedTarget: innerRef2.current}),
599
+ );
600
+ innerRef2.current.dispatchEvent(createEvent('focus'));
601
+ innerRef2.current.dispatchEvent(
602
+ createEvent('blur', {relatedTarget: ref.current}),
603
+ );
604
+ ref.current.dispatchEvent(createEvent('focus'));
605
+ ref.current.dispatchEvent(
606
+ createEvent('blur', {relatedTarget: innerRef.current}),
607
+ );
608
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
609
+ // focus shifts outside subtree
610
+ innerRef.current.dispatchEvent(
611
+ createEvent('blur', {relatedTarget: container}),
612
+ );
613
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
614
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
615
+ });
616
+ });
617
+
618
+ it('expect displayName to show up for event component', () => {
619
+ expect(FocusWithin.responder.displayName).toBe('FocusWithin');
620
+ });
621
+});