Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/react-events/docs/Focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const Button = (props) => {
```js
type FocusEvent = {
target: Element,
type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange'
type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' | 'focuswithinchange' | 'focusvisiblewithinchange'
}
```

Expand All @@ -60,3 +60,13 @@ Called when the element changes focus state (i.e., after `onBlur` and

Called when the element receives or loses focus following keyboard navigation.
This can be used to display focus styles only for keyboard interactions.

### onFocusWithinChange: boolean => void

Called when the element or a descendant changes focus state (i.e., after `onBlur` and
`onFocus`).

### onFocusVisibleWithinChange: boolean => void

Called when the element or a descendant receives or loses focus following keyboard navigation.
This can be used to display focus styles only for keyboard interactions.
119 changes: 101 additions & 18 deletions packages/react-events/src/Focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,25 @@ type FocusProps = {
onFocus: (e: FocusEvent) => void,
onFocusChange: boolean => void,
onFocusVisibleChange: boolean => void,
onFocusWithinChange: boolean => void,
onFocusVisibleWithinChange: boolean => void,
};

type FocusState = {
focusTarget: null | Element | Document,
isFocused: boolean,
isLocalFocusVisible: boolean,
isFocusWithin: boolean,
pointerType: PointerType,
};

type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
type FocusEventType =
| 'focus'
| 'blur'
| 'focuschange'
| 'focusvisiblechange'
| 'focuswithinchange'
| 'focusvisiblewithinchange';

type FocusEvent = {|
target: Element | Document,
Expand Down Expand Up @@ -125,6 +134,38 @@ function dispatchFocusInEvents(
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
}
function dispatchFocusWithinEvents(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
) {
const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
if (props.onFocusWithinChange) {
const listener = () => {
props.onFocusWithinChange(true);
};
const syntheticEvent = createFocusEvent(
context,
'focuswithinchange',
target,
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
if (props.onFocusVisibleWithinChange && state.isLocalFocusVisible) {
const listener = () => {
props.onFocusVisibleWithinChange(true);
};
const syntheticEvent = createFocusEvent(
context,
'focusvisiblewithinchange',
target,
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
}

function dispatchFocusOutEvents(
context: ReactDOMResponderContext,
Expand All @@ -133,7 +174,7 @@ function dispatchFocusOutEvents(
) {
const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
if (props.onBlur) {
if (props.onBlur && state.isFocused) {
const syntheticEvent = createFocusEvent(
context,
'blur',
Expand All @@ -142,7 +183,7 @@ function dispatchFocusOutEvents(
);
context.dispatchEvent(syntheticEvent, props.onBlur, DiscreteEvent);
}
if (props.onFocusChange) {
if (props.onFocusChange && state.isFocused) {
const listener = () => {
props.onFocusChange(false);
};
Expand All @@ -154,17 +195,33 @@ function dispatchFocusOutEvents(
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
dispatchFocusVisibleOutEvent(context, props, state);
if (props.onFocusWithinChange && state.isFocusWithin) {
const listener = () => {
props.onFocusWithinChange(false);
};
const syntheticEvent = createFocusEvent(
context,
'focuswithinchange',
target,
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
dispatchFocusVisibleOutEvents(context, props, state);
}

function dispatchFocusVisibleOutEvent(
function dispatchFocusVisibleOutEvents(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
) {
if (!state.isLocalFocusVisible) {
return;
}

const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
if (props.onFocusVisibleChange && state.isFocused) {
const listener = () => {
props.onFocusVisibleChange(false);
};
Expand All @@ -175,16 +232,28 @@ function dispatchFocusVisibleOutEvent(
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
state.isLocalFocusVisible = false;
}
if (props.onFocusVisibleWithinChange && state.isFocusWithin) {
const listener = () => {
props.onFocusVisibleWithinChange(false);
};
const syntheticEvent = createFocusEvent(
context,
'focusvisiblewithinchange',
target,
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
state.isLocalFocusVisible = false;
}

function unmountResponder(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
): void {
if (state.isFocused) {
if (state.isFocused || state.isFocusWithin) {
dispatchFocusOutEvents(context, props, state);
}
}
Expand All @@ -210,7 +279,7 @@ function handleRootPointerEvent(
state.focusTarget === context.getEventCurrentTarget(event) &&
(type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
) {
dispatchFocusVisibleOutEvent(context, props, state);
dispatchFocusVisibleOutEvents(context, props, state);
}
}

Expand All @@ -224,6 +293,7 @@ const FocusResponder = {
focusTarget: null,
isFocused: false,
isLocalFocusVisible: false,
isFocusWithin: false,
pointerType: '',
};
},
Expand All @@ -247,22 +317,35 @@ const FocusResponder = {

switch (type) {
case 'focus': {
if (!state.isFocused) {
// Limit focus events to the direct child of the event component.
// Browser focus is not expected to bubble.
if (!state.isFocused || !state.isFocusWithin) {
state.focusTarget = context.getEventCurrentTarget(event);
if (state.focusTarget === target) {
state.isFocused = true;
state.isLocalFocusVisible = isGlobalFocusVisible;
dispatchFocusInEvents(context, props, state);
}
}

state.isLocalFocusVisible = isGlobalFocusVisible;

// Limit focus events to the direct child of the event component
// Browser focus is not expected to bubble.
if (!state.isFocused && state.focusTarget === target) {
state.isFocused = true;
dispatchFocusInEvents(context, props, state);
}
if (!state.isFocusWithin) {
state.isFocusWithin = true;
dispatchFocusWithinEvents(context, props, state);
}
break;
}
case 'blur': {
if (state.isFocused) {
// Ignore blur events when focus moves within the subtree
let relatedTarget = ((event.nativeEvent: any).relatedTarget: Element);
if (context.isTargetWithinEventComponent(relatedTarget)) {
break;
}

if (state.isFocused || state.isFocusWithin) {
dispatchFocusOutEvents(context, props, state);
state.isFocused = false;
state.isFocusWithin = false;
state.focusTarget = null;
}
break;
Expand Down
Loading