Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[utils] Bring back useIsFocusVisible #44256

Merged
merged 1 commit into from
Oct 29, 2024
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
1 change: 1 addition & 0 deletions packages/mui-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { default as unstable_useForkRef } from './useForkRef';
export { default as unstable_useLazyRef } from './useLazyRef';
export { default as unstable_useTimeout, Timeout as unstable_Timeout } from './useTimeout';
export { default as unstable_useOnMount } from './useOnMount';
export { default as unstable_useIsFocusVisible } from './useIsFocusVisible';
export { default as unstable_isFocusVisible } from './isFocusVisible';
export { default as unstable_getScrollbarSize } from './getScrollbarSize';
export { default as usePreviousProps } from './usePreviousProps';
Expand Down
2 changes: 2 additions & 0 deletions packages/mui-utils/src/useIsFocusVisible/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './useIsFocusVisible';
export * from './useIsFocusVisible';
126 changes: 126 additions & 0 deletions packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect } from 'chai';
import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';
import {
act,
createRenderer,
focusVisible,
simulatePointerDevice,
programmaticFocusTriggersFocusVisible,
} from '@mui/internal-test-utils';
import useIsFocusVisible, { teardown as teardownFocusVisible } from './useIsFocusVisible';
import useForkRef from '../useForkRef';

const SimpleButton = React.forwardRef(function SimpleButton(props, ref) {
const {
isFocusVisibleRef,
onBlur: handleBlurVisible,
onFocus: handleFocusVisible,
ref: focusVisibleRef,
} = useIsFocusVisible();

const handleRef = useForkRef(focusVisibleRef, ref);

const [isFocusVisible, setIsFocusVisible] = React.useState(false);

const handleBlur = (event) => {
handleBlurVisible(event);
if (isFocusVisibleRef.current === false) {
setIsFocusVisible(false);
}
};

const handleFocus = (event) => {
handleFocusVisible(event);
if (isFocusVisibleRef.current === true) {
setIsFocusVisible(true);
}
};

return (
<button
type="button"
{...props}
ref={handleRef}
className={isFocusVisible ? 'focus-visible' : null}
onBlur={handleBlur}
onFocus={handleFocus}
/>
);
});

describe('useIsFocusVisible', () => {
const { render } = createRenderer();

before(() => {
// isolate test from previous component test that use the polyfill in the document scope
teardownFocusVisible(document);
});

describe('focus inside shadowRoot', () => {
before(function beforeHook() {
// Only run on HeadlessChrome which has native shadowRoot support.
// And jsdom which has limited support for shadowRoot (^12.0.0).
if (!/HeadlessChrome|jsdom/.test(window.navigator.userAgent)) {
this.skip();
}
});

let rootElement;
let reactRoot;

beforeEach(() => {
rootElement = document.createElement('div');
document.body.appendChild(rootElement);
rootElement.attachShadow({ mode: 'open' });
reactRoot = ReactDOMClient.createRoot(rootElement.shadowRoot);
});

afterEach(() => {
act(() => {
reactRoot.unmount();
});

teardownFocusVisible(rootElement.shadowRoot);
document.body.removeChild(rootElement);
});

it('should set focus state for shadowRoot children', () => {
const buttonRef = React.createRef();
render(
<SimpleButton id="test-button" ref={buttonRef}>
Hello
</SimpleButton>,
{},
{
container: rootElement.shadowRoot,
},
);
simulatePointerDevice();

const { current: button } = buttonRef;
if (button.nodeName !== 'BUTTON') {
throw new Error('missing button');
}

expect(button.classList.contains('focus-visible')).to.equal(false);

act(() => {
button.focus();
});

if (programmaticFocusTriggersFocusVisible()) {
expect(button).to.have.class('focus-visible');
} else {
expect(button).not.to.have.class('focus-visible');
}

act(() => {
button.blur();
});
focusVisible(button);

expect(button.classList.contains('focus-visible')).to.equal(true);
});
});
});
175 changes: 175 additions & 0 deletions packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
'use client';
// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js
import * as React from 'react';
import { Timeout } from '../useTimeout/useTimeout';

let hadKeyboardEvent = true;
let hadFocusVisibleRecently = false;
const hadFocusVisibleRecentlyTimeout = new Timeout();

const inputTypesWhitelist: Record<string, boolean> = {
text: true,
search: true,
url: true,
tel: true,
email: true,
password: true,
number: true,
date: true,
month: true,
week: true,
time: true,
datetime: true,
'datetime-local': true,
};

/**
* Computes whether the given element should automatically trigger the
* `focus-visible` class being added, i.e. whether it should always match
* `:focus-visible` when focused.
* @param {Element} node
* @returns {boolean}
*/
function focusTriggersKeyboardModality(node: Element) {
const { type, tagName } = node as HTMLInputElement;

if (tagName === 'INPUT' && inputTypesWhitelist[type] && !(node as HTMLInputElement).readOnly) {
return true;
}

if (tagName === 'TEXTAREA' && !(node as HTMLInputElement).readOnly) {
return true;
}

if ((node as HTMLElement).isContentEditable) {
return true;
}

return false;
}

/**
* Keep track of our keyboard modality state with `hadKeyboardEvent`.
* If the most recent user interaction was via the keyboard;
* and the key press did not include a meta, alt/option, or control key;
* then the modality is keyboard. Otherwise, the modality is not keyboard.
* @param {KeyboardEvent} event
*/
function handleKeyDown(event: KeyboardEvent) {
if (event.metaKey || event.altKey || event.ctrlKey) {
return;
}
hadKeyboardEvent = true;
}

/**
* If at any point a user clicks with a pointing device, ensure that we change
* the modality away from keyboard.
* This avoids the situation where a user presses a key on an already focused
* element, and then clicks on a different element, focusing it with a
* pointing device, while we still think we're in keyboard modality.
*/
function handlePointerDown() {
hadKeyboardEvent = false;
}

function handleVisibilityChange(this: Document) {
if (this.visibilityState === 'hidden') {
// If the tab becomes active again, the browser will handle calling focus
// on the element (Safari actually calls it twice).
// If this tab change caused a blur on an element with focus-visible,
// re-apply the class when the user switches back to the tab.
if (hadFocusVisibleRecently) {
hadKeyboardEvent = true;
}
}
}

function prepare(doc: Document): void {
doc.addEventListener('keydown', handleKeyDown, true);
doc.addEventListener('mousedown', handlePointerDown, true);
doc.addEventListener('pointerdown', handlePointerDown, true);
doc.addEventListener('touchstart', handlePointerDown, true);
doc.addEventListener('visibilitychange', handleVisibilityChange, true);
}

export function teardown(doc: Document): void {
doc.removeEventListener('keydown', handleKeyDown, true);
doc.removeEventListener('mousedown', handlePointerDown, true);
doc.removeEventListener('pointerdown', handlePointerDown, true);
doc.removeEventListener('touchstart', handlePointerDown, true);
doc.removeEventListener('visibilitychange', handleVisibilityChange, true);
}

function isFocusVisible(event: React.FocusEvent): boolean {
const { target } = event;
try {
return target.matches(':focus-visible');
} catch (error) {
// Browsers not implementing :focus-visible will throw a SyntaxError.
// We use our own heuristic for those browsers.
// Rethrow might be better if it's not the expected error but do we really
// want to crash if focus-visible malfunctioned?
}

// No need for validFocusTarget check. The user does that by attaching it to
// focusable events only.
return hadKeyboardEvent || focusTriggersKeyboardModality(target);
}

export interface UseIsFocusVisibleResult {
isFocusVisibleRef: React.MutableRefObject<boolean>;
onBlur: (event: React.FocusEvent<any>) => void;
onFocus: (event: React.FocusEvent<any>) => void;
ref: React.RefCallback<Element>;
}

export default function useIsFocusVisible(): UseIsFocusVisibleResult {
const ref = React.useCallback((node: HTMLElement) => {
if (node != null) {
prepare(node.ownerDocument);
}
}, []);

const isFocusVisibleRef = React.useRef(false);

/**
* Should be called if a blur event is fired
*/
function handleBlurVisible() {
// checking against potential state variable does not suffice if we focus and blur synchronously.
// React wouldn't have time to trigger a re-render so `focusVisible` would be stale.
// Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events.
// This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751
// TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186).
if (isFocusVisibleRef.current) {
// To detect a tab/window switch, we look for a blur event followed
// rapidly by a visibility change.
// If we don't see a visibility change within 100ms, it's probably a
// regular focus change.
hadFocusVisibleRecently = true;
hadFocusVisibleRecentlyTimeout.start(100, () => {
hadFocusVisibleRecently = false;
});

isFocusVisibleRef.current = false;

return true;
}

return false;
}

/**
* Should be called if a blur event is fired
*/
function handleFocusVisible(event: React.FocusEvent) {
if (isFocusVisible(event)) {
isFocusVisibleRef.current = true;
return true;
}
return false;
}

return { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref };
}
Loading