@wordpress/compose
Version:
WordPress higher-order components (HOCs).
133 lines (125 loc) • 4.05 kB
text/typescript
/**
* External dependencies
*/
import type {
KeyboardEvent,
KeyboardEventHandler,
RefCallback,
SyntheticEvent,
} from 'react';
/**
* WordPress dependencies
*/
import { useRef, useEffect, useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import useConstrainedTabbing from '../use-constrained-tabbing';
import { useFocusOnMount } from '../use-focus-on-mount';
import useFocusReturn from '../use-focus-return';
import useFocusOutside from '../use-focus-outside';
import useMergeRefs from '../use-merge-refs';
type DialogOptions = {
/**
* Determines focus behavior when the dialog mounts.
*
* - `"firstElement"` focuses the first tabbable element within.
* - `"firstInputElement"` focuses the first value control within.
* - `true` focuses the element itself.
* - `false` does nothing and _should not be used unless an accessible
* substitute behavior is implemented_.
*
* @default 'firstElement'
*/
focusOnMount?: useFocusOnMount.Mode;
/**
* Determines whether tabbing is constrained to within the popover,
* preventing keyboard focus from leaving the popover content without
* explicit focus elsewhere, or whether the popover remains part of the
* wider tab order.
* If no value is passed, it will be derived from `focusOnMount`.
*
* @see focusOnMount
* @default `focusOnMount` !== false
*/
constrainTabbing?: boolean;
onClose?: () => void;
/**
* Optional `onKeyDown` handler, merged with the built-in one.
*/
onKeyDown?: KeyboardEventHandler< HTMLElement >;
/**
* Use the `onClose` prop instead.
*
* @deprecated
*/
__unstableOnClose?: (
type: string | undefined,
event: SyntheticEvent
) => void;
};
type useDialogReturn = [
RefCallback< HTMLElement >,
ReturnType< typeof useFocusOutside > & {
onKeyDown: ( event: KeyboardEvent< HTMLElement > ) => void;
} & Pick< HTMLElement, 'tabIndex' >,
];
/**
* Returns a ref and props to apply to a dialog wrapper to enable the following behaviors:
* - constrained tabbing.
* - focus on mount.
* - return focus on unmount.
* - focus outside.
*
* @param options Dialog Options.
*/
function useDialog( options: DialogOptions ): useDialogReturn {
const currentOptions = useRef< DialogOptions >( undefined );
const { constrainTabbing = options.focusOnMount !== false } = options;
useEffect( () => {
currentOptions.current = options;
}, Object.values( options ) );
const constrainedTabbingRef = useConstrainedTabbing();
const focusOnMountRef = useFocusOnMount( options.focusOnMount );
const focusReturnRef = useFocusReturn();
const focusOutsideProps = useFocusOutside( ( event ) => {
// This unstable prop is here only to manage backward compatibility
// for the Popover component otherwise, the onClose should be enough.
if ( currentOptions.current?.__unstableOnClose ) {
currentOptions.current.__unstableOnClose( 'focus-outside', event );
} else if ( currentOptions.current?.onClose ) {
currentOptions.current.onClose();
}
} );
// Close on Escape via a React `onKeyDown` (rather than a native listener)
// so portaled descendants that handle Escape and call
// `event.stopPropagation()` correctly prevent the dialog from closing.
// See https://github.com/WordPress/gutenberg/issues/78432.
const onKeyDown = useCallback( ( event: KeyboardEvent< HTMLElement > ) => {
// Let the consumer-provided handler (if any) run first so it can
// call `preventDefault()` to opt out of close-on-Escape.
currentOptions.current?.onKeyDown?.( event );
if (
event.key === 'Escape' &&
! event.defaultPrevented &&
currentOptions.current?.onClose
) {
event.preventDefault();
event.stopPropagation();
currentOptions.current.onClose();
}
}, [] );
return [
useMergeRefs( [
constrainTabbing ? constrainedTabbingRef : null,
options.focusOnMount !== false ? focusReturnRef : null,
options.focusOnMount !== false ? focusOnMountRef : null,
] ),
{
...focusOutsideProps,
onKeyDown,
tabIndex: -1,
},
];
}
export default useDialog;