@samitouri / QOS-React / commits / 2cca18728e

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();