diff --git a/docs/pages/base/api/focus-trap.json b/docs/pages/base/api/focus-trap.json index 15b34f7040272a..7152d1a72eb1f1 100644 --- a/docs/pages/base/api/focus-trap.json +++ b/docs/pages/base/api/focus-trap.json @@ -8,13 +8,13 @@ "getTabbable": { "type": { "name": "func" } }, "isEnabled": { "type": { "name": "func" }, - "default": "function defaultIsEnabled() {\n return true;\n}" + "default": "function defaultIsEnabled(): boolean {\n return true;\n}" } }, "name": "FocusTrap", "styles": { "classes": [], "globalClasses": {}, "name": null }, "spread": false, - "filename": "/packages/mui-base/src/FocusTrap/FocusTrap.js", + "filename": "/packages/mui-base/src/FocusTrap/FocusTrap.tsx", "inheritance": null, "demos": "", "cssComponent": false diff --git a/packages/mui-base/src/FocusTrap/FocusTrap.test.js b/packages/mui-base/src/FocusTrap/FocusTrap.test.tsx similarity index 92% rename from packages/mui-base/src/FocusTrap/FocusTrap.test.js rename to packages/mui-base/src/FocusTrap/FocusTrap.test.tsx index 4bc53f663cebc8..a9704c772d70c4 100644 --- a/packages/mui-base/src/FocusTrap/FocusTrap.test.js +++ b/packages/mui-base/src/FocusTrap/FocusTrap.test.tsx @@ -5,22 +5,26 @@ import { act, createRenderer, screen } from 'test/utils'; import FocusTrap from '@mui/base/FocusTrap'; import Portal from '@mui/base/Portal'; +interface GenericProps { + [index: string]: any; +} + describe('', () => { const { clock, render } = createRenderer(); - let initialFocus = null; + let initialFocus: HTMLElement | null = null; beforeEach(() => { initialFocus = document.createElement('button'); initialFocus.tabIndex = 0; document.body.appendChild(initialFocus); act(() => { - initialFocus.focus(); + initialFocus!.focus(); }); }); afterEach(() => { - document.body.removeChild(initialFocus); + document.body.removeChild(initialFocus!); }); it('should return focus to the root', () => { @@ -37,7 +41,7 @@ describe('', () => { expect(getByTestId('auto-focus')).toHaveFocus(); act(() => { - initialFocus.focus(); + initialFocus!.focus(); }); expect(getByTestId('root')).toHaveFocus(); }); @@ -56,7 +60,7 @@ describe('', () => { expect(getByTestId('auto-focus')).toHaveFocus(); act(() => { - initialFocus.focus(); + initialFocus!.focus(); }); expect(initialFocus).toHaveFocus(); @@ -77,7 +81,7 @@ describe('', () => { }); it('should warn if the root content is not focusable', () => { - const UnfocusableDialog = React.forwardRef((_, ref) =>
); + const UnfocusableDialog = React.forwardRef((_, ref) =>
); expect(() => { render( @@ -110,7 +114,7 @@ describe('', () => { }); it('does not steal focus from a portaled element if any prop but open changes', () => { - function Test(props) { + function Test(props: GenericProps) { return (
@@ -147,8 +151,11 @@ describe('', () => { }); it('undesired: lazy root does not get autofocus', () => { - let mountDeferredComponent; - const DeferredComponent = React.forwardRef(function DeferredComponent(props, ref) { + let mountDeferredComponent: React.DispatchWithoutAction; + const DeferredComponent = React.forwardRef(function DeferredComponent( + props, + ref, + ) { const [mounted, setMounted] = React.useReducer(() => true, false); mountDeferredComponent = setMounted; @@ -177,8 +184,8 @@ describe('', () => { }); it('does not bounce focus around due to sync focus-restore + focus-contain', () => { - const eventLog = []; - function Test(props) { + const eventLog: string[] = []; + function Test(props: GenericProps) { return (
eventLog.push('blur')}> @@ -203,7 +210,7 @@ describe('', () => { }); it('does not focus if isEnabled returns false', () => { - function Test(props) { + function Test(props: GenericProps) { return (
@@ -230,7 +237,7 @@ describe('', () => { }); it('restores focus when closed', () => { - function Test(props) { + function Test(props: GenericProps) { return (
@@ -247,7 +254,7 @@ describe('', () => { }); it('undesired: enabling restore-focus logic when closing has no effect', () => { - function Test(props) { + function Test(props: GenericProps) { return (
@@ -265,7 +272,7 @@ describe('', () => { }); it('undesired: setting `disableRestoreFocus` to false before closing has no effect', () => { - function Test(props) { + function Test(props: GenericProps) { return (
@@ -341,7 +348,7 @@ describe('', () => {
-
, @@ -368,7 +375,7 @@ describe('', () => { }); it('should restore the focus', () => { - function Test(props) { + function Test(props: GenericProps) { return (
diff --git a/packages/mui-base/src/FocusTrap/FocusTrap.js b/packages/mui-base/src/FocusTrap/FocusTrap.tsx similarity index 79% rename from packages/mui-base/src/FocusTrap/FocusTrap.js rename to packages/mui-base/src/FocusTrap/FocusTrap.tsx index c03dd7cc57fda9..962387189a2ea3 100644 --- a/packages/mui-base/src/FocusTrap/FocusTrap.js +++ b/packages/mui-base/src/FocusTrap/FocusTrap.tsx @@ -7,6 +7,7 @@ import { unstable_useForkRef as useForkRef, unstable_ownerDocument as ownerDocument, } from '@mui/utils'; +import { FocusTrapProps } from './FocusTrap.types'; // Inspired by https://github.com/focus-trap/tabbable const candidatesSelector = [ @@ -21,8 +22,14 @@ const candidatesSelector = [ '[contenteditable]:not([contenteditable="false"])', ].join(','); -function getTabIndex(node) { - const tabindexAttr = parseInt(node.getAttribute('tabindex'), 10); +interface OrderedTabNode { + documentOrder: number; + tabIndex: number; + node: HTMLElement; +} + +function getTabIndex(node: HTMLElement): number { + const tabindexAttr = parseInt(node.getAttribute('tabindex') || '', 10); if (!Number.isNaN(tabindexAttr)) { return tabindexAttr; @@ -47,7 +54,7 @@ function getTabIndex(node) { return node.tabIndex; } -function isNonTabbableRadio(node) { +function isNonTabbableRadio(node: HTMLInputElement): boolean { if (node.tagName !== 'INPUT' || node.type !== 'radio') { return false; } @@ -56,7 +63,8 @@ function isNonTabbableRadio(node) { return false; } - const getRadio = (selector) => node.ownerDocument.querySelector(`input[type="radio"]${selector}`); + const getRadio = (selector: string) => + node.ownerDocument.querySelector(`input[type="radio"]${selector}`); let roving = getRadio(`[name="${node.name}"]:checked`); @@ -67,7 +75,7 @@ function isNonTabbableRadio(node) { return roving !== node; } -function isNodeMatchingSelectorFocusable(node) { +function isNodeMatchingSelectorFocusable(node: HTMLInputElement): boolean { if ( node.disabled || (node.tagName === 'INPUT' && node.type === 'hidden') || @@ -78,24 +86,24 @@ function isNodeMatchingSelectorFocusable(node) { return true; } -function defaultGetTabbable(root) { - const regularTabNodes = []; - const orderedTabNodes = []; +function defaultGetTabbable(root: HTMLElement): HTMLElement[] { + const regularTabNodes: HTMLElement[] = []; + const orderedTabNodes: OrderedTabNode[] = []; Array.from(root.querySelectorAll(candidatesSelector)).forEach((node, i) => { - const nodeTabIndex = getTabIndex(node); + const nodeTabIndex = getTabIndex(node as HTMLElement); - if (nodeTabIndex === -1 || !isNodeMatchingSelectorFocusable(node)) { + if (nodeTabIndex === -1 || !isNodeMatchingSelectorFocusable(node as HTMLInputElement)) { return; } if (nodeTabIndex === 0) { - regularTabNodes.push(node); + regularTabNodes.push(node as HTMLElement); } else { orderedTabNodes.push({ documentOrder: i, tabIndex: nodeTabIndex, - node, + node: node as HTMLElement, }); } }); @@ -108,14 +116,22 @@ function defaultGetTabbable(root) { .concat(regularTabNodes); } -function defaultIsEnabled() { +function defaultIsEnabled(): boolean { return true; } /** * Utility component that locks focus inside the component. + * + * Demos: + * + * - [Focus Trap](https://mui.com/base/react-focus-trap/) + * + * API: + * + * - [FocusTrap API](https://mui.com/base/api/focus-trap/) */ -function FocusTrap(props) { +function FocusTrap(props: FocusTrapProps) { const { children, disableAutoFocus = false, @@ -125,18 +141,19 @@ function FocusTrap(props) { isEnabled = defaultIsEnabled, open, } = props; - const ignoreNextEnforceFocus = React.useRef(); - const sentinelStart = React.useRef(null); - const sentinelEnd = React.useRef(null); - const nodeToRestore = React.useRef(null); - const reactFocusEventTarget = React.useRef(null); + const ignoreNextEnforceFocus = React.useRef(false); + const sentinelStart = React.useRef(null); + const sentinelEnd = React.useRef(null); + const nodeToRestore = React.useRef(null); + const reactFocusEventTarget = React.useRef(null); // This variable is useful when disableAutoFocus is true. // It waits for the active element to move into the component to activate. const activated = React.useRef(false); - const rootRef = React.useRef(null); + const rootRef = React.useRef(null); + // @ts-expect-error TODO upstream fix const handleRef = useForkRef(children.ref, rootRef); - const lastKeydown = React.useRef(null); + const lastKeydown = React.useRef(null); React.useEffect(() => { // We might render an empty child. @@ -166,7 +183,7 @@ function FocusTrap(props) { ].join('\n'), ); } - rootRef.current.setAttribute('tabIndex', -1); + rootRef.current.setAttribute('tabIndex', '-1'); } if (activated.current) { @@ -181,9 +198,9 @@ function FocusTrap(props) { // in nodeToRestore.current being null. // Not all elements in IE11 have a focus method. // Once IE11 support is dropped the focus() call can be unconditional. - if (nodeToRestore.current && nodeToRestore.current.focus) { + if (nodeToRestore.current && (nodeToRestore.current as HTMLElement).focus) { ignoreNextEnforceFocus.current = true; - nodeToRestore.current.focus(); + (nodeToRestore.current as HTMLElement).focus(); } nodeToRestore.current = null; @@ -202,8 +219,9 @@ function FocusTrap(props) { const doc = ownerDocument(rootRef.current); - const contain = (nativeEvent) => { + const contain = (nativeEvent: FocusEvent | null) => { const { current: rootElement } = rootRef; + // Cleanup functions are executed lazily in React 17. // Contain can be called between the component being unmounted and its cleanup function being run. if (rootElement === null) { @@ -235,12 +253,12 @@ function FocusTrap(props) { return; } - let tabbable = []; + let tabbable: string[] | HTMLElement[] = []; if ( doc.activeElement === sentinelStart.current || doc.activeElement === sentinelEnd.current ) { - tabbable = getTabbable(rootRef.current); + tabbable = getTabbable(rootRef.current as HTMLElement); } if (tabbable.length > 0) { @@ -251,10 +269,12 @@ function FocusTrap(props) { const focusNext = tabbable[0]; const focusPrevious = tabbable[tabbable.length - 1]; - if (isShiftTab) { - focusPrevious.focus(); - } else { - focusNext.focus(); + if (typeof focusNext !== 'string' && typeof focusPrevious !== 'string') { + if (isShiftTab) { + focusPrevious.focus(); + } else { + focusNext.focus(); + } } } else { rootElement.focus(); @@ -262,7 +282,7 @@ function FocusTrap(props) { } }; - const loopFocus = (nativeEvent) => { + const loopFocus = (nativeEvent: KeyboardEvent) => { lastKeydown.current = nativeEvent; if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') { @@ -275,7 +295,9 @@ function FocusTrap(props) { // We need to ignore the next contain as // it will try to move the focus back to the rootRef element. ignoreNextEnforceFocus.current = true; - sentinelEnd.current.focus(); + if (sentinelEnd.current) { + sentinelEnd.current.focus(); + } } }; @@ -289,8 +311,8 @@ function FocusTrap(props) { // The whatwg spec defines how the browser should behave but does not explicitly mention any events: // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule. const interval = setInterval(() => { - if (doc.activeElement.tagName === 'BODY') { - contain(); + if (doc.activeElement && doc.activeElement.tagName === 'BODY') { + contain(null); } }, 50); @@ -302,7 +324,7 @@ function FocusTrap(props) { }; }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]); - const onFocus = (event) => { + const onFocus = (event: FocusEvent) => { if (nodeToRestore.current === null) { nodeToRestore.current = event.relatedTarget; } @@ -315,7 +337,7 @@ function FocusTrap(props) { } }; - const handleFocusSentinel = (event) => { + const handleFocusSentinel = (event: React.FocusEvent) => { if (nodeToRestore.current === null) { nodeToRestore.current = event.relatedTarget; } @@ -344,7 +366,7 @@ function FocusTrap(props) { FocusTrap.propTypes /* remove-proptypes */ = { // ----------------------------- Warning -------------------------------- // | These PropTypes are generated from the TypeScript type definitions | - // | To update them edit the d.ts file and run "yarn proptypes" | + // | To update them edit TypeScript types and run "yarn proptypes" | // ---------------------------------------------------------------------- /** * A single child content element. @@ -385,7 +407,7 @@ FocusTrap.propTypes /* remove-proptypes */ = { * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop. * This prop should be memoized. * It can be used to support multiple focus trap mounted at the same time. - * @default function defaultIsEnabled() { + * @default function defaultIsEnabled(): boolean { * return true; * } */ @@ -394,11 +416,11 @@ FocusTrap.propTypes /* remove-proptypes */ = { * If `true`, focus is locked. */ open: PropTypes.bool.isRequired, -}; +} as any; if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line - FocusTrap['propTypes' + ''] = exactProp(FocusTrap.propTypes); + (FocusTrap as any)['propTypes' + ''] = exactProp(FocusTrap.propTypes); } export default FocusTrap; diff --git a/packages/mui-base/src/FocusTrap/FocusTrap.d.ts b/packages/mui-base/src/FocusTrap/FocusTrap.types.ts similarity index 82% rename from packages/mui-base/src/FocusTrap/FocusTrap.d.ts rename to packages/mui-base/src/FocusTrap/FocusTrap.types.ts index 55cc5d7d88bfb6..90a746233ea97e 100644 --- a/packages/mui-base/src/FocusTrap/FocusTrap.d.ts +++ b/packages/mui-base/src/FocusTrap/FocusTrap.types.ts @@ -16,7 +16,7 @@ export interface FocusTrapProps { * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop. * This prop should be memoized. * It can be used to support multiple focus trap mounted at the same time. - * @default function defaultIsEnabled() { + * @default function defaultIsEnabled(): boolean { * return true; * } */ @@ -24,7 +24,7 @@ export interface FocusTrapProps { /** * A single child content element. */ - children: React.ReactElement; + children: React.ReactElement; /** * If `true`, the focus trap will not automatically shift focus to itself when it opens, and * replace it to the last focused element when it closes. @@ -50,16 +50,3 @@ export interface FocusTrapProps { */ disableRestoreFocus?: boolean; } - -/** - * Utility component that locks focus inside the component. - * - * Demos: - * - * - [Focus Trap](https://mui.com/base/react-focus-trap/) - * - * API: - * - * - [FocusTrap API](https://mui.com/base/api/focus-trap/) - */ -export default function FocusTrap(props: FocusTrapProps): JSX.Element; diff --git a/packages/mui-base/src/FocusTrap/index.d.ts b/packages/mui-base/src/FocusTrap/index.d.ts deleted file mode 100644 index 6206922d9df2c0..00000000000000 --- a/packages/mui-base/src/FocusTrap/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './FocusTrap'; -export * from './FocusTrap'; diff --git a/packages/mui-base/src/FocusTrap/index.js b/packages/mui-base/src/FocusTrap/index.ts similarity index 52% rename from packages/mui-base/src/FocusTrap/index.js rename to packages/mui-base/src/FocusTrap/index.ts index 5131ffa9169f3a..93f0fe84d13a4a 100644 --- a/packages/mui-base/src/FocusTrap/index.js +++ b/packages/mui-base/src/FocusTrap/index.ts @@ -1 +1,2 @@ export { default } from './FocusTrap'; +export * from './FocusTrap.types';