Skip to content
Merged
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
24 changes: 22 additions & 2 deletions packages/@react-aria/calendar/src/useCalendarBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {DOMProps} from '@react-types/shared';
import intlMessages from '../intl/*.json';
import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils';
import {useMessageFormatter} from '@react-aria/i18n';
import {useRef} from 'react';

export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: CalendarState | RangeCalendarState): CalendarAria {
let formatMessage = useMessageFormatter(intlMessages);
Expand Down Expand Up @@ -49,6 +50,21 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
// Label the child grid elements by the group element if it is labelled.
calendarIds.set(state, props['aria-label'] || props['aria-labelledby'] ? calendarId : null);

// If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
let nextFocused = useRef(false);
let nextDisabled = props.isDisabled || state.isNextVisibleRangeInvalid();
if (nextDisabled && nextFocused.current) {
nextFocused.current = false;
state.setFocused(true);
}

let previousFocused = useRef(false);
let previousDisabled = props.isDisabled || state.isPreviousVisibleRangeInvalid();
if (previousDisabled && previousFocused.current) {
previousFocused.current = false;
state.setFocused(true);
}

return {
calendarProps: mergeProps(descriptionProps, {
role: 'group',
Expand All @@ -59,12 +75,16 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
nextButtonProps: {
onPress: () => state.focusNextPage(),
'aria-label': formatMessage('next'),
isDisabled: props.isDisabled || state.isNextVisibleRangeInvalid()
isDisabled: nextDisabled,
onFocus: () => nextFocused.current = true,
onBlur: () => nextFocused.current = false
},
prevButtonProps: {
onPress: () => state.focusPreviousPage(),
'aria-label': formatMessage('previous'),
isDisabled: props.isDisabled || state.isPreviousVisibleRangeInvalid()
isDisabled: previousDisabled,
onFocus: () => previousFocused.current = true,
onBlur: () => previousFocused.current = false
},
title: visibleRangeDescription
};
Expand Down
36 changes: 21 additions & 15 deletions packages/@react-aria/interactions/src/useFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import {FocusEvent, HTMLAttributes} from 'react';
import {FocusEvents} from '@react-types/shared';
import {useSyntheticBlurEvent} from './utils';

interface FocusProps extends FocusEvents {
/** Whether the focus events should be disabled. */
Expand All @@ -33,35 +34,40 @@ interface FocusResult {
* Focus events on child elements will be ignored.
*/
export function useFocus(props: FocusProps): FocusResult {
if (props.isDisabled) {
return {focusProps: {}};
}

let onFocus, onBlur;
if (props.onFocus || props.onFocusChange) {
onFocus = (e: FocusEvent) => {
let onBlur: FocusProps['onBlur'];
if (!props.isDisabled && (props.onBlur || props.onFocusChange)) {
onBlur = (e: FocusEvent) => {
if (e.target === e.currentTarget) {
if (props.onFocus) {
props.onFocus(e);
if (props.onBlur) {
props.onBlur(e);
}

if (props.onFocusChange) {
props.onFocusChange(true);
props.onFocusChange(false);
}

return true;
}
};
} else {
onBlur = null;
}

if (props.onBlur || props.onFocusChange) {
onBlur = (e: FocusEvent) => {
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);

let onFocus: FocusProps['onFocus'];
if (!props.isDisabled && (props.onFocus || props.onFocusChange || props.onBlur)) {
onFocus = (e: FocusEvent) => {
if (e.target === e.currentTarget) {
if (props.onBlur) {
props.onBlur(e);
if (props.onFocus) {
props.onFocus(e);
}

if (props.onFocusChange) {
props.onFocusChange(false);
props.onFocusChange(true);
}

onSyntheticFocus(e);
}
};
}
Expand Down
41 changes: 20 additions & 21 deletions packages/@react-aria/interactions/src/useFocusWithin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions

import {FocusEvent, HTMLAttributes, useRef} from 'react';
import {useSyntheticBlurEvent} from './utils';

interface FocusWithinProps {
/** Whether the focus within events should be disabled. */
Expand All @@ -41,45 +42,43 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
isFocusWithin: false
}).current;

if (props.isDisabled) {
return {focusWithinProps: {}};
}
let onBlur = props.isDisabled ? null : (e: FocusEvent) => {
// We don't want to trigger onBlurWithin and then immediately onFocusWithin again
// when moving focus inside the element. Only trigger if the currentTarget doesn't
// include the relatedTarget (where focus is moving).
if (state.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) {
state.isFocusWithin = false;

let onFocus = (e: FocusEvent) => {
if (!state.isFocusWithin) {
if (props.onFocusWithin) {
props.onFocusWithin(e);
if (props.onBlurWithin) {
props.onBlurWithin(e);
}

if (props.onFocusWithinChange) {
props.onFocusWithinChange(true);
props.onFocusWithinChange(false);
}

state.isFocusWithin = true;
}
};

let onBlur = (e: FocusEvent) => {
// We don't want to trigger onBlurWithin and then immediately onFocusWithin again
// when moving focus inside the element. Only trigger if the currentTarget doesn't
// include the relatedTarget (where focus is moving).
if (state.isFocusWithin && !e.currentTarget.contains(e.relatedTarget as HTMLElement)) {
if (props.onBlurWithin) {
props.onBlurWithin(e);
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
let onFocus = props.isDisabled ? null : (e: FocusEvent) => {
if (!state.isFocusWithin) {
if (props.onFocusWithin) {
props.onFocusWithin(e);
}

if (props.onFocusWithinChange) {
props.onFocusWithinChange(false);
props.onFocusWithinChange(true);
}

state.isFocusWithin = false;
state.isFocusWithin = true;
onSyntheticFocus(e);
}
};

return {
focusWithinProps: {
onFocus: onFocus,
onBlur: onBlur
onFocus,
onBlur
}
};
}
117 changes: 117 additions & 0 deletions packages/@react-aria/interactions/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
* governing permissions and limitations under the License.
*/

import {FocusEvent as ReactFocusEvent, useRef} from 'react';
import {useLayoutEffect} from '@react-aria/utils';

// Original licensing for the following method can be found in the
// NOTICE file in the root directory of this source tree.
// See https://github.com/facebook/react/blob/3c713d513195a53788b3f8bb4b70279d68b15bcc/packages/react-interactions/events/src/dom/shared/index.js#L74-L87
Expand All @@ -29,3 +32,117 @@ export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {

return event.detail === 0 && !(event as PointerEvent).pointerType;
}

export class SyntheticFocusEvent implements ReactFocusEvent {
nativeEvent: FocusEvent;
target: Element;
currentTarget: Element;
relatedTarget: Element;
bubbles: boolean;
cancelable: boolean;
defaultPrevented: boolean;
eventPhase: number;
isTrusted: boolean;
timeStamp: number;
type: string;

constructor(type: string, nativeEvent: FocusEvent) {
this.nativeEvent = nativeEvent;
this.target = nativeEvent.target as Element;
this.currentTarget = nativeEvent.currentTarget as Element;
this.relatedTarget = nativeEvent.relatedTarget as Element;
this.bubbles = nativeEvent.bubbles;
this.cancelable = nativeEvent.cancelable;
this.defaultPrevented = nativeEvent.defaultPrevented;
this.eventPhase = nativeEvent.eventPhase;
this.isTrusted = nativeEvent.isTrusted;
this.timeStamp = nativeEvent.timeStamp;
this.type = type;
}

isDefaultPrevented(): boolean {
return this.nativeEvent.defaultPrevented;
}

preventDefault(): void {
this.defaultPrevented = true;
this.nativeEvent.preventDefault();
}

stopPropagation(): void {
this.nativeEvent.stopPropagation();
this.isPropagationStopped = () => true;
}

isPropagationStopped(): boolean {
return false;
}

persist() {}
}

export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEvent) => void) {
let stateRef = useRef({
isFocused: false,
onBlur,
observer: null as MutationObserver
});
let state = stateRef.current;
state.onBlur = onBlur;

// Clean up MutationObserver on unmount. See below.
// eslint-disable-next-line arrow-body-style

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a silly lint rule and we should consider removing it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotta wait until we find a new lint rule to add =P

useLayoutEffect(() => {
Comment thread
devongovett marked this conversation as resolved.
return () => {
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
};
}, [state]);

// This function is called during a React onFocus event.
return (e: ReactFocusEvent) => {
// React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142
// Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a
// MutationObserver to watch for the disabled attribute, and dispatch these events ourselves.
// For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice.
if (
e.target instanceof HTMLButtonElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
state.isFocused = true;

let target = e.target;
let onBlurHandler = (e: FocusEvent) => {
let state = stateRef.current;
state.isFocused = false;

if (target.disabled) {
// For backward compatibility, dispatch a (fake) React synthetic event.
state.onBlur?.(new SyntheticFocusEvent('blur', e));
}

// We no longer need the MutationObserver once the target is blurred.
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
};

target.addEventListener('focusout', onBlurHandler, {once: true});

state.observer = new MutationObserver(() => {
if (state.isFocused && target.disabled) {
state.observer.disconnect();
target.dispatchEvent(new FocusEvent('blur'));
target.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
}
});

state.observer.observe(target, {attributes: true, attributeFilter: ['disabled']});
}
};
}
20 changes: 19 additions & 1 deletion packages/@react-aria/interactions/test/useFocus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {act, render} from '@testing-library/react';
import {act, render, waitFor} from '@testing-library/react';
import React from 'react';
import {useFocus} from '../';

Expand Down Expand Up @@ -135,4 +135,22 @@ describe('useFocus', function () {
expect(onWrapperFocus).toHaveBeenCalledTimes(1);
expect(onWrapperBlur).toHaveBeenCalledTimes(1);
});

it('should fire onBlur when a focused element is disabled', async function () {
function Example(props) {
let {focusProps} = useFocus(props);
return <button disabled={props.disabled} {...focusProps}>Button</button>;
}

let onFocus = jest.fn();
let onBlur = jest.fn();
let tree = render(<Example onFocus={onFocus} onBlur={onBlur} />);
let button = tree.getByRole('button');

act(() => {button.focus();});
expect(onFocus).toHaveBeenCalled();
tree.rerender(<Example disabled onFocus={onFocus} onBlur={onBlur} />);
// MutationObserver is async
await waitFor(() => expect(onBlur).toHaveBeenCalled());
});
});
20 changes: 19 additions & 1 deletion packages/@react-aria/interactions/test/useFocusWithin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {act, render} from '@testing-library/react';
import {act, render, waitFor} from '@testing-library/react';
import React from 'react';
import {useFocusWithin} from '../';

Expand Down Expand Up @@ -138,4 +138,22 @@ describe('useFocusWithin', function () {
expect(onWrapperFocus).toHaveBeenCalledTimes(1);
expect(onWrapperBlur).toHaveBeenCalledTimes(1);
});

it('should fire onBlur when a focused element is disabled', async function () {
function Example(props) {
let {focusWithinProps} = useFocusWithin(props);
return <div {...focusWithinProps}><button disabled={props.disabled}>Button</button></div>;
}

let onFocus = jest.fn();
let onBlur = jest.fn();
let tree = render(<Example onFocusWithin={onFocus} onBlurWithin={onBlur} />);
let button = tree.getByRole('button');

act(() => {button.focus();});
expect(onFocus).toHaveBeenCalled();
tree.rerender(<Example disabled onFocusWithin={onFocus} onBlurWithin={onBlur} />);
// MutationObserver is async
await waitFor(() => expect(onBlur).toHaveBeenCalled());
});
});
1 change: 1 addition & 0 deletions packages/@react-spectrum/calendar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@react-spectrum/button": "^3.7.1",
"@react-spectrum/utils": "^3.6.5",
"@react-stately/calendar": "3.0.0-alpha.3",
"@react-types/button": "^3.4.3",
"@react-types/calendar": "3.0.0-alpha.3",
"@react-types/shared": "^3.11.1",
"@spectrum-icons/ui": "^3.2.3",
Expand Down
Loading