@ariakit/react-core
Version:
Ariakit React core
498 lines (495 loc) • 15.9 kB
JavaScript
"use client";
import {
usePortal
} from "./M5DFOEFU.js";
import {
HeadingLevel
} from "./5M6RIVE2.js";
import {
useFocusableContainer
} from "./LC6GJMGV.js";
import {
prependHiddenDismiss
} from "./6GXEOXGT.js";
import {
useHideOnInteractOutside
} from "./JZEJYXOQ.js";
import {
useNestedDialogs
} from "./PVECYOSC.js";
import {
usePreventBodyScroll
} from "./SOMPWLIQ.js";
import {
disableTree,
disableTreeOutside
} from "./Z5GCVBAY.js";
import {
supportsInert
} from "./677M2CI3.js";
import {
DialogBackdrop
} from "./FVE2C5B3.js";
import {
isElementMarked,
markTreeOutside
} from "./3NDVDEB4.js";
import {
createWalkTreeSnapshot
} from "./AOUGVQZ3.js";
import {
isHidden,
useDisclosureContent
} from "./K4R5DNTX.js";
import {
useDialogStore
} from "./Y2U4BRIM.js";
import {
DialogDescriptionContext,
DialogHeadingContext,
DialogScopedContextProvider,
useDialogProviderContext
} from "./T2AZQXQU.js";
import {
useFocusable
} from "./OE2EFRVA.js";
import {
useStoreState
} from "./RTNCFSKZ.js";
import {
createElement,
createHook,
forwardRef
} from "./VOQWLFSQ.js";
import {
useBooleanEvent,
useEvent,
useId,
useMergeRefs,
usePortalRef,
useSafeLayoutEffect,
useWrapElement
} from "./5GGHRIN3.js";
import {
__objRest,
__spreadProps,
__spreadValues
} from "./3YLGPPWQ.js";
// src/dialog/dialog.tsx
import {
contains,
getActiveElement,
getDocument,
getWindow,
isButton
} from "@ariakit/core/utils/dom";
import {
addGlobalEventListener,
queueBeforeEvent
} from "@ariakit/core/utils/events";
import {
focusIfNeeded,
getFirstTabbableIn,
isFocusable
} from "@ariakit/core/utils/focus";
import { chain } from "@ariakit/core/utils/misc";
import { isSafari } from "@ariakit/core/utils/platform";
import { useCallback, useEffect, useRef, useState } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var TagName = "div";
var isSafariBrowser = isSafari();
function isAlreadyFocusingAnotherElement(dialog) {
const activeElement = getActiveElement();
if (!activeElement) return false;
if (dialog && contains(dialog, activeElement)) return false;
if (isFocusable(activeElement)) return true;
return false;
}
function getElementFromProp(prop, focusable = false) {
if (!prop) return null;
const element = "current" in prop ? prop.current : prop;
if (!element) return null;
if (focusable) return isFocusable(element) ? element : null;
return element;
}
var useDialog = createHook(function useDialog2(_a) {
var _b = _a, {
store: storeProp,
open: openProp,
onClose,
focusable = true,
modal = true,
portal = !!modal,
backdrop = !!modal,
hideOnEscape = true,
hideOnInteractOutside = true,
getPersistentElements,
preventBodyScroll = !!modal,
autoFocusOnShow = true,
autoFocusOnHide = true,
initialFocus,
finalFocus,
unmountOnHide,
unstable_treeSnapshotKey
} = _b, props = __objRest(_b, [
"store",
"open",
"onClose",
"focusable",
"modal",
"portal",
"backdrop",
"hideOnEscape",
"hideOnInteractOutside",
"getPersistentElements",
"preventBodyScroll",
"autoFocusOnShow",
"autoFocusOnHide",
"initialFocus",
"finalFocus",
"unmountOnHide",
"unstable_treeSnapshotKey"
]);
const context = useDialogProviderContext();
const ref = useRef(null);
const store = useDialogStore({
store: storeProp || context,
open: openProp,
setOpen(open2) {
if (open2) return;
const dialog = ref.current;
if (!dialog) return;
const event = new Event("close", { bubbles: false, cancelable: true });
if (onClose) {
dialog.addEventListener("close", onClose, { once: true });
}
dialog.dispatchEvent(event);
if (!event.defaultPrevented) return;
store.setOpen(true);
}
});
const { portalRef, domReady } = usePortalRef(portal, props.portalRef);
const preserveTabOrderProp = props.preserveTabOrder;
const preserveTabOrder = useStoreState(
store,
(state) => preserveTabOrderProp && !modal && state.mounted
);
const id = useId(props.id);
const open = useStoreState(store, "open");
const mounted = useStoreState(store, "mounted");
const contentElement = useStoreState(store, "contentElement");
const hidden = isHidden(mounted, props.hidden, props.alwaysVisible);
usePreventBodyScroll(contentElement, id, preventBodyScroll && !hidden);
useHideOnInteractOutside(store, hideOnInteractOutside, domReady);
const { wrapElement, nestedDialogs } = useNestedDialogs(store);
props = useWrapElement(props, wrapElement, [wrapElement]);
useSafeLayoutEffect(() => {
if (!open) return;
const dialog = ref.current;
const activeElement = getActiveElement(dialog, true);
if (!activeElement) return;
if (activeElement.tagName === "BODY") return;
if (dialog && contains(dialog, activeElement)) return;
store.setDisclosureElement(activeElement);
}, [store, open]);
if (isSafariBrowser) {
useEffect(() => {
if (!mounted) return;
const { disclosureElement } = store.getState();
if (!disclosureElement) return;
if (!isButton(disclosureElement)) return;
const onMouseDown = () => {
let receivedFocus = false;
const onFocus = () => {
receivedFocus = true;
};
const options = { capture: true, once: true };
disclosureElement.addEventListener("focusin", onFocus, options);
queueBeforeEvent(disclosureElement, "mouseup", () => {
disclosureElement.removeEventListener("focusin", onFocus, true);
if (receivedFocus) return;
focusIfNeeded(disclosureElement);
});
};
disclosureElement.addEventListener("mousedown", onMouseDown);
return () => {
disclosureElement.removeEventListener("mousedown", onMouseDown);
};
}, [store, mounted]);
}
useEffect(() => {
if (!mounted) return;
if (!domReady) return;
const dialog = ref.current;
if (!dialog) return;
const win = getWindow(dialog);
const viewport = win.visualViewport || win;
const setViewportHeight = () => {
var _a2, _b2;
const height = (_b2 = (_a2 = win.visualViewport) == null ? void 0 : _a2.height) != null ? _b2 : win.innerHeight;
dialog.style.setProperty("--dialog-viewport-height", `${height}px`);
};
setViewportHeight();
viewport.addEventListener("resize", setViewportHeight);
return () => {
viewport.removeEventListener("resize", setViewportHeight);
};
}, [mounted, domReady]);
useEffect(() => {
if (!modal) return;
if (!mounted) return;
if (!domReady) return;
const dialog = ref.current;
if (!dialog) return;
const existingDismiss = dialog.querySelector("[data-dialog-dismiss]");
if (existingDismiss) return;
return prependHiddenDismiss(dialog, store.hide);
}, [store, modal, mounted, domReady]);
useSafeLayoutEffect(() => {
if (!supportsInert()) return;
if (open) return;
if (!mounted) return;
if (!domReady) return;
const dialog = ref.current;
if (!dialog) return;
return disableTree(dialog);
}, [open, mounted, domReady]);
const canTakeTreeSnapshot = open && domReady;
useSafeLayoutEffect(() => {
if (!id) return;
if (!canTakeTreeSnapshot) return;
const dialog = ref.current;
return createWalkTreeSnapshot(id, [dialog]);
}, [id, canTakeTreeSnapshot, unstable_treeSnapshotKey]);
const getPersistentElementsProp = useEvent(getPersistentElements);
useSafeLayoutEffect(() => {
if (!id) return;
if (!canTakeTreeSnapshot) return;
const { disclosureElement } = store.getState();
const dialog = ref.current;
const persistentElements = getPersistentElementsProp() || [];
const allElements = [
dialog,
...persistentElements,
...nestedDialogs.map((dialog2) => dialog2.getState().contentElement)
];
if (modal) {
return chain(
markTreeOutside(id, allElements),
disableTreeOutside(id, allElements)
);
}
return markTreeOutside(id, [disclosureElement, ...allElements]);
}, [
id,
store,
canTakeTreeSnapshot,
getPersistentElementsProp,
nestedDialogs,
modal,
unstable_treeSnapshotKey
]);
const mayAutoFocusOnShow = !!autoFocusOnShow;
const autoFocusOnShowProp = useBooleanEvent(autoFocusOnShow);
const [autoFocusEnabled, setAutoFocusEnabled] = useState(false);
useEffect(() => {
if (!open) return;
if (!mayAutoFocusOnShow) return;
if (!domReady) return;
if (!(contentElement == null ? void 0 : contentElement.isConnected)) return;
const element = getElementFromProp(initialFocus, true) || // If no initial focus is specified, we try to focus the first element
// with the autofocus attribute. If it's an Ariakit component, the
// Focusable component will consume the autoFocus prop and add the
// data-autofocus attribute to the element instead.
contentElement.querySelector(
"[data-autofocus=true],[autofocus]"
) || // We have to fallback to the first focusable element otherwise portaled
// dialogs with preserveTabOrder set to true will not receive focus
// properly because the elements aren't tabbable until the dialog receives
// focus.
getFirstTabbableIn(contentElement, true, portal && preserveTabOrder) || // Finally, we fallback to the dialog element itself.
contentElement;
const isElementFocusable = isFocusable(element);
if (!autoFocusOnShowProp(isElementFocusable ? element : null)) return;
setAutoFocusEnabled(true);
queueMicrotask(() => {
element.focus();
if (!isSafariBrowser) return;
if (!isElementFocusable) return;
element.scrollIntoView({ block: "nearest", inline: "nearest" });
});
}, [
open,
mayAutoFocusOnShow,
domReady,
contentElement,
initialFocus,
portal,
preserveTabOrder,
autoFocusOnShowProp
]);
const mayAutoFocusOnHide = !!autoFocusOnHide;
const autoFocusOnHideProp = useBooleanEvent(autoFocusOnHide);
const [hasOpened, setHasOpened] = useState(false);
useEffect(() => {
if (!open) return;
setHasOpened(true);
return () => setHasOpened(false);
}, [open]);
const focusOnHide = useCallback(
(dialog, retry = true) => {
const { disclosureElement } = store.getState();
if (isAlreadyFocusingAnotherElement(dialog)) return;
let element = getElementFromProp(finalFocus) || disclosureElement;
if (element == null ? void 0 : element.id) {
const doc = getDocument(element);
const selector = `[aria-activedescendant="${element.id}"]`;
const composite = doc.querySelector(selector);
if (composite) {
element = composite;
}
}
if (element && !isFocusable(element)) {
const maybeParentDialog = element.closest("[data-dialog]");
if (maybeParentDialog == null ? void 0 : maybeParentDialog.id) {
const doc = getDocument(maybeParentDialog);
const selector = `[aria-controls~="${maybeParentDialog.id}"]`;
const control = doc.querySelector(selector);
if (control) {
element = control;
}
}
}
const isElementFocusable = element && isFocusable(element);
if (!isElementFocusable && retry) {
requestAnimationFrame(() => focusOnHide(dialog, false));
return;
}
if (!autoFocusOnHideProp(isElementFocusable ? element : null)) return;
if (!isElementFocusable) return;
element == null ? void 0 : element.focus({ preventScroll: true });
},
[store, finalFocus, autoFocusOnHideProp]
);
const focusedOnHideRef = useRef(false);
useSafeLayoutEffect(() => {
if (open) return;
if (!hasOpened) return;
if (!mayAutoFocusOnHide) return;
const dialog = ref.current;
focusedOnHideRef.current = true;
focusOnHide(dialog);
}, [open, hasOpened, domReady, mayAutoFocusOnHide, focusOnHide]);
useEffect(() => {
if (!hasOpened) return;
if (!mayAutoFocusOnHide) return;
const dialog = ref.current;
return () => {
if (focusedOnHideRef.current) {
focusedOnHideRef.current = false;
return;
}
focusOnHide(dialog);
};
}, [hasOpened, mayAutoFocusOnHide, focusOnHide]);
const hideOnEscapeProp = useBooleanEvent(hideOnEscape);
useEffect(() => {
if (!domReady) return;
if (!mounted) return;
const onKeyDown = (event) => {
if (event.key !== "Escape") return;
if (event.defaultPrevented) return;
const dialog = ref.current;
if (!dialog) return;
if (isElementMarked(dialog)) return;
const target = event.target;
if (!target) return;
const { disclosureElement } = store.getState();
const isValidTarget = () => {
if (target.tagName === "BODY") return true;
if (contains(dialog, target)) return true;
if (!disclosureElement) return true;
if (contains(disclosureElement, target)) return true;
return false;
};
if (!isValidTarget()) return;
if (!hideOnEscapeProp(event)) return;
store.hide();
};
return addGlobalEventListener("keydown", onKeyDown, true);
}, [store, domReady, mounted, hideOnEscapeProp]);
props = useWrapElement(
props,
(element) => /* @__PURE__ */ jsx(HeadingLevel, { level: modal ? 1 : void 0, children: element }),
[modal]
);
const hiddenProp = props.hidden;
const alwaysVisible = props.alwaysVisible;
props = useWrapElement(
props,
(element) => {
if (!backdrop) return element;
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
DialogBackdrop,
{
store,
backdrop,
hidden: hiddenProp,
alwaysVisible
}
),
element
] });
},
[store, backdrop, hiddenProp, alwaysVisible]
);
const [headingId, setHeadingId] = useState();
const [descriptionId, setDescriptionId] = useState();
props = useWrapElement(
props,
(element) => /* @__PURE__ */ jsx(DialogScopedContextProvider, { value: store, children: /* @__PURE__ */ jsx(DialogHeadingContext.Provider, { value: setHeadingId, children: /* @__PURE__ */ jsx(DialogDescriptionContext.Provider, { value: setDescriptionId, children: element }) }) }),
[store]
);
props = __spreadProps(__spreadValues({
id,
"data-dialog": "",
role: "dialog",
tabIndex: focusable ? -1 : void 0,
"aria-labelledby": headingId,
"aria-describedby": descriptionId
}, props), {
ref: useMergeRefs(ref, props.ref)
});
props = useFocusableContainer(__spreadProps(__spreadValues({}, props), {
autoFocusOnShow: autoFocusEnabled
}));
props = useDisclosureContent(__spreadValues({ store }, props));
props = useFocusable(__spreadProps(__spreadValues({}, props), { focusable }));
props = usePortal(__spreadProps(__spreadValues({ portal }, props), { portalRef, preserveTabOrder }));
return props;
});
function createDialogComponent(Component, useProviderContext = useDialogProviderContext) {
return forwardRef(function DialogComponent(props) {
const context = useProviderContext();
const store = props.store || context;
const mounted = useStoreState(
store,
(state) => !props.unmountOnHide || (state == null ? void 0 : state.mounted) || !!props.open
);
if (!mounted) return null;
return /* @__PURE__ */ jsx(Component, __spreadValues({}, props));
});
}
var Dialog = createDialogComponent(
forwardRef(function Dialog2(props) {
const htmlProps = useDialog(props);
return createElement(TagName, htmlProps);
}),
useDialogProviderContext
);
export {
useDialog,
createDialogComponent,
Dialog
};