UNPKG

@ariakit/react-core

Version:

Ariakit React core

498 lines (495 loc) 15.9 kB
"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 };