Skip to content

Commit

Permalink
Bring back useIsFocusVisible
Browse files Browse the repository at this point in the history
  • Loading branch information
aarongarciah committed Oct 29, 2024
1 parent 9b9f562 commit 51b7cbd
Show file tree
Hide file tree
Showing 4 changed files with 304 additions and 0 deletions.
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 };
}

0 comments on commit 51b7cbd

Please sign in to comment.