Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -743,8 +743,11 @@ describe('CheckboxGroup', () => {
let group = getByRole('group');
expect(group).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(group).toHaveAttribute('aria-describedby');
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
5 changes: 4 additions & 1 deletion packages/@adobe/react-spectrum/test/radio/Radio.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1051,8 +1051,11 @@ describe('Radios', function () {
let group = getByRole('radiogroup');
expect(group).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(group).toHaveAttribute('aria-describedby');
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,8 +768,11 @@ describe('Shared TextField behavior', () => {
let input = getByTestId('input');
expect(input).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(input).toHaveAttribute('aria-describedby');
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
14 changes: 14 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/useFocusVisible.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ in CSS.
To determine whether a focus ring should be visible for an individual component rather than
globally, see [useFocusRing](useFocusRing).

### Making focus visible

For the most part, focus visibility is handled automatically by `useFocusVisible`. However, if you need to
manually set the interaction modality, such as moving focus programmatically to the first item
of an invalid form, you can use `setInteractionModality`.

```tsx
import {setInteractionModality} from 'react-aria/useFocusVisible';

setInteractionModality('keyboard');
```



## API

<FunctionAPI function={docs.exports.useFocusVisible} links={docs.links} />
Expand Down
2 changes: 2 additions & 0 deletions packages/react-aria-components/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export {Focusable} from 'react-aria/Focusable';
export {VisuallyHidden} from 'react-aria/VisuallyHidden';
export {FormValidationContext} from 'react-stately/private/form/useFormValidationState';
export {parseColor, getColorChannels} from 'react-stately/Color';
export {setInteractionModality} from 'react-aria/useFocusVisible';
export {ToastQueue as UNSTABLE_ToastQueue} from 'react-stately/useToastState';
export {useListData} from 'react-stately/useListData';
export {useTreeData} from 'react-stately/useTreeData';
Expand Down Expand Up @@ -608,6 +609,7 @@ export type {DateRangePickerState} from 'react-stately/useDateRangePickerState';
export type {DisclosureState} from 'react-stately/useDisclosureState';
export type {DisclosureGroupState} from 'react-stately/useDisclosureGroupState';
export type {ListState} from 'react-stately/useListState';
export type {Modality} from 'react-aria/useFocusVisible';
export type {NumberFieldState} from 'react-stately/useNumberFieldState';
export type {OverlayTriggerState} from 'react-stately/useOverlayTriggerState';
export type {QueuedToast, ToastOptions, ToastState} from 'react-stately/useToastState';
Expand Down
32 changes: 32 additions & 0 deletions packages/react-aria-components/test/Form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,38 @@ describe('Form', () => {
expect(form).toHaveAttribute('data-custom', 'true');
});

(parseInt(React.version, 10) >= 19 ? it : it.skip)('shows focus-visible when a form library moves focus to the first invalid field on submit', async () => {
function Test() {
return (
<form data-testid="form">
<Input aria-label="Name" name="name" required />
<Button type="submit">Submit</Button>
</form>
);
}

let {getByRole} = render(<Test />);
let input = getByRole('textbox');
let button = getByRole('button');

await user.click(button);
expect(input).not.toHaveAttribute('data-focus-visible');

// On submit the form is invalid, so the browser fires an invalid event on
// the required field. react-hook-form (shouldFocusError) then moves focus
// to the first invalid field with a plain ref.focus().
act(() => {
input.checkValidity();
input.focus();
});
await act(async () => {
await Promise.resolve();
});

expect(document.activeElement).toBe(input);
expect(input).toHaveAttribute('data-focus-visible');
});

it('should not throw when form contains elements without validity property', async () => {
function Test() {
return (
Expand Down
9 changes: 6 additions & 3 deletions packages/react-aria/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ export {
export {useNumberFormatter} from '../src/i18n/useNumberFormatter';
export {useListFormatter} from '../src/i18n/useListFormatter';
export {useFocus} from '../src/interactions/useFocus';
export {useFocusVisible} from '../src/interactions/useFocusVisible';
export {useShowFocusIndicator} from '../src/interactions/useFocusVisible';
export {setInteractionModality, useFocusVisible} from '../src/interactions/useFocusVisible';
export {useFocusWithin} from '../src/interactions/useFocusWithin';
export {useHover} from '../src/interactions/useHover';
export {useInteractOutside} from '../src/interactions/useInteractOutside';
Expand Down Expand Up @@ -331,7 +330,11 @@ export type {
} from '../src/dnd/useDroppableCollection';
export type {DroppableItemOptions, DroppableItemResult} from '../src/dnd/useDroppableItem';
export type {FocusProps, FocusResult} from '../src/interactions/useFocus';
export type {FocusVisibleProps, FocusVisibleResult} from '../src/interactions/useFocusVisible';
export type {
Modality,
FocusVisibleProps,
FocusVisibleResult
} from '../src/interactions/useFocusVisible';
export type {FocusWithinProps, FocusWithinResult} from '../src/interactions/useFocusWithin';
export type {HoverProps, HoverResult} from '../src/interactions/useHover';
export type {InteractOutsideProps} from '../src/interactions/useInteractOutside';
Expand Down
3 changes: 2 additions & 1 deletion packages/react-aria/exports/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

export {useFocusVisible} from '../src/interactions/useFocusVisible';
export {setInteractionModality, useFocusVisible} from '../src/interactions/useFocusVisible';
export type {FocusVisibleProps, FocusVisibleResult} from '../src/interactions/useFocusVisible';
export type {FocusEvents} from '@react-types/shared';
export type {Modality} from '../src/interactions/useFocusVisible';
13 changes: 0 additions & 13 deletions packages/react-aria/exports/useShowFocusIndicator.ts

This file was deleted.

25 changes: 17 additions & 8 deletions packages/react-aria/src/interactions/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {isMac} from '../utils/platform';
import {isVirtualClick} from '../utils/isVirtualEvent';
import {openLink} from '../utils/openLink';
import {PointerType} from '@react-types/shared';
import {useCallback, useEffect, useState} from 'react';
import {useEffect, useState} from 'react';
import {useIsSSR} from '../ssr/SSRProvider';

export type Modality = 'keyboard' | 'pointer' | 'virtual';
Expand Down Expand Up @@ -147,6 +147,18 @@ function handleWindowBlur() {
hasBlurredWindowRecently = true;
}

function handleInvalidEvent(e: Event) {
let startingActiveElement = getActiveElement(getOwnerDocument(getEventTarget(e)));
queueMicrotask(() => {
// If focus was moved to a different element after the form became invalid,
// then it was likely a forms library that moved focus to the first invalid field.
// In this case, we want to set the modality to keyboard.
if (getActiveElement(getOwnerDocument(getEventTarget(e))) !== startingActiveElement) {
setInteractionModality('keyboard');
}
});
}

/**
* Setup global event listeners to control when keyboard focus style should be visible.
*/
Expand Down Expand Up @@ -184,6 +196,8 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) {
documentObject.addEventListener('keyup', handleKeyboardEvent, true);
documentObject.addEventListener('click', handleClickEvent, true);

documentObject.addEventListener('invalid', handleInvalidEvent, true);

// Register focus events on the window so they are sure to happen
// before React's event listeners (registered on the document).
windowObject.addEventListener('focus', handleFocusEvent, true);
Expand Down Expand Up @@ -230,6 +244,8 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => {
documentObject.removeEventListener('keyup', handleKeyboardEvent, true);
documentObject.removeEventListener('click', handleClickEvent, true);

documentObject.removeEventListener('invalid', handleInvalidEvent, true);

windowObject.removeEventListener('focus', handleFocusEvent, true);
windowObject.removeEventListener('blur', handleWindowBlur, false);

Expand Down Expand Up @@ -303,13 +319,6 @@ export function setInteractionModality(modality: Modality): void {
triggerChangeHandlers(modality, null);
}

/**
* Returns a callback that makes the focus indicator visible.
*/
export function useShowFocusIndicator(): () => void {
return useCallback(() => setInteractionModality('keyboard'), []);
}

/** @private */
export function getPointerType(): PointerType {
return currentPointerType;
Expand Down
37 changes: 1 addition & 36 deletions packages/react-aria/test/interactions/useFocusVisible.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import {
import {
addWindowFocusTracking,
useFocusVisible,
useFocusVisibleListener,
useShowFocusIndicator
useFocusVisibleListener
} from '../../src/interactions/useFocusVisible';
import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible';
import {mergeProps} from '../../src/utils/mergeProps';
Expand Down Expand Up @@ -376,40 +375,6 @@ describe('useFocusVisible', function () {
});
});

describe('useShowFocusIndicator', function () {
// A form library that moves focus to the first invalid field does so by calling
// element.focus() from the submit handler. Clicking submit leaves the modality on
// 'pointer', so the programmatic focus lands without a visible indicator.
function FormExample() {
let {focusProps, isFocusVisible} = useFocusRing();
let showFocusIndicator = useShowFocusIndicator();
let ref = React.useRef(null);

let onSubmit = e => {
e.preventDefault();
showFocusIndicator();
ref.current.focus();
};

return (
<form onSubmit={onSubmit}>
<input {...focusProps} ref={ref} data-focus-visible={isFocusVisible || undefined} />
<button type="submit">Submit</button>
</form>
);
}

it('shows the focus indicator on programmatic focus after a pointer interaction', async function () {
let user = userEvent.setup({delay: null, pointerMap});
render(<FormExample />);
await user.click(screen.getByRole('button', {name: 'Submit'}));

let input = screen.getByRole('textbox');
expect(input).toHaveFocus();
expect(input).toHaveAttribute('data-focus-visible');
});
});

describe('useFocusVisibleListener', function () {
it('emits on modality change (non-text input)', function () {
let fnMock = jest.fn();
Expand Down