@hypothesis/frontend-shared
Version:
Shared components, styles and utilities for Hypothesis projects
198 lines (194 loc) • 6.71 kB
JavaScript
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/feedback/Dialog.tsx";
import classnames from 'classnames';
import { Fragment } from 'preact';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useClickAway } from '../../hooks/use-click-away';
import { useFocusAway } from '../../hooks/use-focus-away';
import { useKeyPress } from '../../hooks/use-key-press';
import { useSyncedRef } from '../../hooks/use-synced-ref';
import { downcastRef } from '../../util/typing';
import CloseableContext from '../CloseableContext';
import Panel from '../layout/Panel';
// This component forwards a number of props on to `Panel` but always sets the
// `fullWidthHeader` prop to `true`.
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "preact/jsx-dev-runtime";
function isPanelProps(props) {
return props.variant !== 'custom';
}
/**
* Show a dialog
*/
export default function Dialog({
closeOnClickAway = false,
closeOnEscape = false,
closeOnFocusAway = false,
children,
initialFocus = 'auto',
restoreFocus = false,
transitionComponent: TransitionComponent,
classes,
elementRef,
onClose,
closeTitle,
...rest
}) {
const isPanel = isPanelProps(rest);
const {
buttons,
icon,
paddingSize = 'md',
title = '',
scrollable = true,
...htmlAttributes
} = isPanel ? rest : {
buttons: undefined,
icon: undefined,
paddingSize: undefined,
title: undefined,
scrollable: undefined,
...rest
};
const modalRef = useSyncedRef(elementRef);
const restoreFocusEl = useRef(document.activeElement);
const [transitionComponentVisible, setTransitionComponentVisible] = useState(false);
const closeHandler = useCallback(() => {
if (TransitionComponent) {
// When a TransitionComponent is provided, the actual "onClose" will be
// called by that component, once the "out" transition has finished
setTransitionComponentVisible(false);
} else {
onClose === null || onClose === void 0 || onClose();
}
}, [onClose, TransitionComponent]);
const initializeDialog = useCallback(() => {
if (initialFocus === 'manual') {
return;
}
if (initialFocus === 'auto') {
var _modalRef$current;
// An explicit `initialFocus` has not been set, so use automatic focus
// handling. Modern accessibility guidance is to focus the dialog itself
// rather than trying to be smart about focusing a particular control
// within the dialog.
(_modalRef$current = modalRef.current) === null || _modalRef$current === void 0 || _modalRef$current.focus();
return;
}
const focusEl = initialFocus === null || initialFocus === void 0 ? void 0 : initialFocus.current;
if (focusEl && !focusEl.disabled) {
focusEl.focus();
} else {
var _modalRef$current2;
// Fall back to focusing the modal itself
(_modalRef$current2 = modalRef.current) === null || _modalRef$current2 === void 0 || _modalRef$current2.focus();
}
}, [initialFocus, modalRef]);
const onTransitionEnd = direction => {
if (direction === 'in') {
initializeDialog();
} else {
onClose === null || onClose === void 0 || onClose();
}
};
useClickAway(modalRef, closeHandler, {
enabled: closeOnClickAway
});
useKeyPress(['Escape'], closeHandler, {
enabled: closeOnEscape
});
useFocusAway(modalRef, closeHandler, {
enabled: closeOnFocusAway
});
const dialogDescriptionId = useId();
const Wrapper = useMemo(() => TransitionComponent !== null && TransitionComponent !== void 0 ? TransitionComponent : Fragment, [TransitionComponent]);
useEffect(() => {
setTransitionComponentVisible(true);
if (!TransitionComponent) {
initializeDialog();
}
// We only want to run this effect once when the dialog is mounted.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useLayoutEffect(
/**
* Restore focus when component is unmounted, if `restoreFocus` is set.
*/
() => {
const restoreFocusTo = restoreFocusEl.current;
return () => {
if (restoreFocus && restoreFocusTo) {
restoreFocusTo.focus();
}
};
},
// We only want to run this effect once when the dialog is mounted.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]);
useLayoutEffect(
/**
* Try to assign the dialog an accessible description, using the content of
* the first paragraph of text within it.
*
* A limitation of this approach is that it doesn't update if the dialog's
* content changes after the initial render.
*/
() => {
var _modalRef$current3;
const description = modalRef === null || modalRef === void 0 || (_modalRef$current3 = modalRef.current) === null || _modalRef$current3 === void 0 ? void 0 : _modalRef$current3.querySelector('p');
if (description) {
description.id = dialogDescriptionId;
modalRef.current.setAttribute('aria-describedby', dialogDescriptionId);
}
}, [dialogDescriptionId, modalRef]);
// Provide a close handler to descendant components
const closeableContext = {
onClose: onClose ? closeHandler : undefined,
title: closeTitle
};
return _jsxDEV(CloseableContext.Provider, {
value: closeableContext,
children: _jsxDEV(Wrapper, {
direction: transitionComponentVisible ? 'in' : 'out',
onTransitionEnd: onTransitionEnd,
children: _jsxDEV("div", {
"data-component": "Dialog",
tabIndex: -1
// NB: Role can be overridden with an HTML attribute; this is purposeful
,
role: "dialog",
...htmlAttributes,
className: classnames(
// Column-flex layout to constrain content to max-height
'flex flex-col', classes),
ref: downcastRef(modalRef),
children: isPanel ? _jsxDEV(Panel, {
buttons: buttons,
fullWidthHeader: true,
icon: icon,
paddingSize: paddingSize,
title: title,
scrollable: scrollable,
children: children
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 257,
columnNumber: 13
}, this) : _jsxDEV(_Fragment, {
children: children
}, void 0, false)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 243,
columnNumber: 9
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 239,
columnNumber: 7
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 238,
columnNumber: 5
}, this);
}
//# sourceMappingURL=Dialog.js.map