refactor(overlays): improve stability, and complexity (#3467)

* feat(hooks): new `use-aria-overlay` package

* refactor(overlays): use custom `useAriaOverlay`

* chore(modal): remove unnecessary `onClose: state.close`

* chore(popover): remove duplicate `ariaHideOutside`

* test(modal): only hide the top-most modal

* chore: add changeset

* refactor(hooks): useAriaOverlay tweaks

---------

Co-authored-by: WK Wong <wingkwong.code@gmail.com>
This commit is contained in:
chirokas 2024-09-05 22:06:32 +08:00 committed by GitHub
parent 81da063d6a
commit 123b7fbc9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 387 additions and 154 deletions

View File

@ -0,0 +1,14 @@
---
"@nextui-org/autocomplete": patch
"@nextui-org/date-picker": patch
"@nextui-org/dropdown": patch
"@nextui-org/modal": patch
"@nextui-org/popover": patch
"@nextui-org/select": patch
"@nextui-org/use-aria-modal-overlay": patch
"@nextui-org/use-aria-multiselect": patch
"@nextui-org/use-aria-overlay": patch
"@nextui-org/aria-utils": patch
---
Refactor overlays to reduce its complexity, while improving stability.

View File

@ -34,24 +34,23 @@
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"framer-motion": ">=10.17.0",
"@nextui-org/system": ">=2.0.0",
"@nextui-org/theme": ">=2.1.0",
"@nextui-org/system": ">=2.0.0"
"framer-motion": ">=10.17.0",
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/input": "workspace:*",
"@nextui-org/listbox": "workspace:*",
"@nextui-org/popover": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/scroll-shadow": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/spinner": "workspace:*",
"@nextui-org/input": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/use-aria-button": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/use-safe-layout-effect": "workspace:*",
"@react-aria/combobox": "3.9.1",
"@react-aria/focus": "3.17.1",
@ -64,15 +63,15 @@
"@react-types/shared": "3.23.1"
},
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/avatar": "workspace:*",
"@nextui-org/chip": "workspace:*",
"@nextui-org/stories-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/use-infinite-scroll": "workspace:*",
"@react-stately/data": "3.11.4",
"framer-motion": "^11.0.28",
"clean-package": "2.2.0",
"framer-motion": "^11.0.28",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.51.3"

View File

@ -18,7 +18,6 @@ import {chain, mergeProps} from "@react-aria/utils";
import {ButtonProps} from "@nextui-org/button";
import {AsyncLoadable, PressEvent} from "@react-types/shared";
import {useComboBox} from "@react-aria/combobox";
import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils";
interface Props<T> extends Omit<HTMLNextUIProps<"input">, keyof ComboBoxProps<T>> {
/**
@ -444,9 +443,6 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state),
// when the popover is open, the focus should be on input instead of dialog
// therefore, we skip dialog focus here
disableDialogFocus: true,

View File

@ -40,29 +40,28 @@
"react-dom": ">=18"
},
"dependencies": {
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/popover": "workspace:*",
"@nextui-org/calendar": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/date-input": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/aria-utils": "workspace:*",
"@react-stately/overlays": "3.6.7",
"@react-stately/utils": "3.10.1",
"@internationalized/date": "^3.5.4",
"@nextui-org/button": "workspace:*",
"@nextui-org/calendar": "workspace:*",
"@nextui-org/date-input": "workspace:*",
"@nextui-org/popover": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@react-aria/datepicker": "3.10.1",
"@react-aria/i18n": "3.11.1",
"@react-aria/utils": "3.24.1",
"@react-stately/datepicker": "3.9.4",
"@react-stately/overlays": "3.6.7",
"@react-stately/utils": "3.10.1",
"@react-types/datepicker": "3.7.4",
"@react-types/shared": "3.23.1",
"@react-aria/utils": "3.24.1"
"@react-types/shared": "3.23.1"
},
"devDependencies": {
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/radio": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/test-utils": "workspace:*",
"@nextui-org/theme": "workspace:*",
"clean-package": "2.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"

View File

@ -15,7 +15,6 @@ import {useDatePickerState} from "@react-stately/datepicker";
import {AriaDatePickerProps, useDatePicker as useAriaDatePicker} from "@react-aria/datepicker";
import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils";
import {mergeProps} from "@react-aria/utils";
import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils";
import {useDatePickerBase} from "./use-date-picker-base";
@ -193,9 +192,6 @@ export function useDatePicker<T extends DateValue>({
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, popoverTriggerRef, state),
};
};

View File

@ -21,7 +21,6 @@ import {useDateRangePicker as useAriaDateRangePicker} from "@react-aria/datepick
import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils";
import {mergeProps} from "@react-aria/utils";
import {dateRangePicker, dateInput, cn} from "@nextui-org/theme";
import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils";
import {useDatePickerBase} from "./use-date-picker-base";
interface Props<T extends DateValue>
@ -216,9 +215,6 @@ export function useDateRangePicker<T extends DateValue>({
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, popoverTriggerRef, state),
} as PopoverProps;
};

View File

@ -34,35 +34,34 @@
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"framer-motion": ">=10.17.0",
"@nextui-org/system": ">=2.0.0",
"@nextui-org/theme": ">=2.1.0",
"@nextui-org/system": ">=2.0.0"
"framer-motion": ">=10.17.0",
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@nextui-org/menu": "workspace:*",
"@nextui-org/popover": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@react-aria/focus": "3.17.1",
"@react-aria/menu": "3.14.1",
"@react-aria/utils": "3.24.1",
"@react-stately/menu": "3.7.1",
"@react-aria/focus": "3.17.1",
"@react-types/menu": "3.9.9"
},
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/avatar": "workspace:*",
"@nextui-org/user": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/image": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/test-utils": "workspace:*",
"framer-motion": "^11.0.22",
"@nextui-org/theme": "workspace:*",
"@nextui-org/user": "workspace:*",
"clean-package": "2.2.0",
"framer-motion": "^11.0.22",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},

View File

@ -9,7 +9,6 @@ import {useMenuTrigger} from "@react-aria/menu";
import {dropdown} from "@nextui-org/theme";
import {clsx} from "@nextui-org/shared-utils";
import {ReactRef, mergeRefs} from "@nextui-org/react-utils";
import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils";
import {useMemo, useRef} from "react";
import {mergeProps} from "@react-aria/utils";
import {MenuProps} from "@nextui-org/menu";
@ -123,9 +122,6 @@ export function useDropdown(props: UseDropdownProps) {
...props.classNames,
content: clsx(classNames, classNamesProp?.content, props.className),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state),
};
};

View File

@ -1,5 +1,6 @@
import * as React from "react";
import {act, render, fireEvent} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../src";
@ -108,4 +109,38 @@ describe("Modal", () => {
fireEvent.keyDown(modal, {key: "Escape"});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("should only hide the top-most modal", async () => {
const onClose1 = jest.fn();
const onClose2 = jest.fn();
render(
<Modal isOpen onClose={onClose1}>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
const wrapper2 = render(
<Modal isOpen onClose={onClose2}>
<ModalContent>
<ModalHeader>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>,
);
await userEvent.click(document.body);
expect(onClose1).not.toHaveBeenCalled();
expect(onClose2).toHaveBeenCalledTimes(1);
wrapper2.unmount();
await userEvent.click(document.body);
expect(onClose1).toHaveBeenCalledTimes(1);
});
});

View File

@ -169,7 +169,6 @@ export function useModal(originalProps: UseModalProps) {
const getBackdropProps = useCallback<PropGetter>(
(props = {}) => ({
className: slots.backdrop({class: classNames?.backdrop}),
onClick: () => state.close(),
...underlayProps,
...props,
}),

View File

@ -47,6 +47,7 @@
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/use-aria-button": "workspace:*",
"@nextui-org/use-aria-overlay": "workspace:*",
"@nextui-org/use-safe-layout-effect": "workspace:*",
"@react-aria/dialog": "3.5.14",
"@react-aria/focus": "3.17.1",

View File

@ -1,20 +1,15 @@
import {RefObject, useEffect} from "react";
import {
AriaPopoverProps,
useOverlay,
PopoverAria,
useOverlayPosition,
AriaOverlayProps,
} from "@react-aria/overlays";
import {
OverlayPlacement,
ariaHideOutside,
toReactAriaPlacement,
ariaShouldCloseOnInteractOutside,
} from "@nextui-org/aria-utils";
import {OverlayPlacement, ariaHideOutside, toReactAriaPlacement} from "@nextui-org/aria-utils";
import {OverlayTriggerState} from "@react-stately/overlays";
import {mergeProps} from "@react-aria/utils";
import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect";
import {useAriaOverlay} from "@nextui-org/use-aria-overlay";
export interface Props {
/**
@ -71,16 +66,16 @@ export function useReactAriaPopover(
const isNonModal = isNonModalProp ?? true;
const {overlayProps, underlayProps} = useOverlay(
const {overlayProps, underlayProps} = useAriaOverlay(
{
isOpen: state.isOpen,
onClose: state.close,
shouldCloseOnBlur,
isDismissable,
isKeyboardDismissDisabled,
shouldCloseOnInteractOutside: shouldCloseOnInteractOutside
? shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state),
shouldCloseOnInteractOutside:
shouldCloseOnInteractOutside || ((el) => !triggerRef.current?.contains(el)),
disableOutsideEvents: !isNonModal,
},
popoverRef,
);

View File

@ -2,11 +2,11 @@ import type {PopoverVariantProps, SlotsToClasses, PopoverSlots} from "@nextui-or
import type {HTMLMotionProps} from "framer-motion";
import type {PressEvent} from "@react-types/shared";
import {RefObject, Ref, useEffect} from "react";
import {RefObject, Ref} from "react";
import {ReactRef, useDOMRef} from "@nextui-org/react-utils";
import {OverlayTriggerState, useOverlayTriggerState} from "@react-stately/overlays";
import {useFocusRing} from "@react-aria/focus";
import {ariaHideOutside, useOverlayTrigger, usePreventScroll} from "@react-aria/overlays";
import {useOverlayTrigger, usePreventScroll} from "@react-aria/overlays";
import {OverlayTriggerProps} from "@react-types/overlays";
import {
HTMLNextUIProps,
@ -263,8 +263,7 @@ export function usePopover(originalProps: UsePopoverProps) {
return {
"data-slot": "trigger",
"aria-haspopup": "dialog",
...mergeProps(triggerProps, otherProps),
...mergeProps({"aria-haspopup": "dialog"}, triggerProps, otherProps),
onPress,
isDisabled,
className: slots.trigger({
@ -299,12 +298,6 @@ export function usePopover(originalProps: UsePopoverProps) {
[slots, state.isOpen, classNames, underlayProps],
);
useEffect(() => {
if (state.isOpen && domRef?.current) {
return ariaHideOutside([domRef?.current]);
}
}, [state.isOpen, domRef]);
usePreventScroll({
isDisabled: !(shouldBlockScroll && state.isOpen),
});

View File

@ -34,21 +34,20 @@
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"framer-motion": ">=10.17.0",
"@nextui-org/system": ">=2.0.0",
"@nextui-org/theme": ">=2.1.0",
"@nextui-org/system": ">=2.0.0"
"framer-motion": ">=10.17.0",
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/listbox": "workspace:*",
"@nextui-org/popover": "workspace:*",
"@nextui-org/spinner": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/scroll-shadow": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/spinner": "workspace:*",
"@nextui-org/use-aria-button": "workspace:*",
"@nextui-org/use-aria-multiselect": "workspace:*",
"@nextui-org/use-safe-layout-effect": "workspace:*",
@ -60,18 +59,18 @@
"@react-types/shared": "3.23.1"
},
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/avatar": "workspace:*",
"@nextui-org/input": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/chip": "workspace:*",
"@nextui-org/input": "workspace:*",
"@nextui-org/stories-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/use-infinite-scroll": "workspace:*",
"framer-motion": "^11.0.28",
"@react-aria/i18n": "3.11.1",
"@react-stately/data": "3.11.4",
"clean-package": "2.2.0",
"framer-motion": "^11.0.28",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.51.3"

View File

@ -28,7 +28,6 @@ import {
} from "@nextui-org/use-aria-multiselect";
import {SpinnerProps} from "@nextui-org/spinner";
import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect";
import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils";
import {CollectionChildren} from "@react-types/shared";
export type SelectedItemProps<T = object> = {
@ -530,9 +529,6 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
? // forces the popover to update its position when the selected items change
state.selectedItems.length * 0.00000001 + (slotsProps.popoverProps?.offset || 0)
: slotsProps.popoverProps?.offset,
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state),
} as PopoverProps;
},
[

View File

@ -34,6 +34,7 @@
"postpack": "clean-package restore"
},
"dependencies": {
"@nextui-org/use-aria-overlay": "workspace:*",
"@react-aria/overlays": "3.22.1",
"@react-aria/utils": "3.24.1",
"@react-stately/overlays": "3.6.7",

View File

@ -1,8 +1,8 @@
import {useAriaOverlay} from "@nextui-org/use-aria-overlay";
import {
ariaHideOutside,
AriaModalOverlayProps,
ModalOverlayAria,
useOverlay,
usePreventScroll,
useOverlayFocusContain,
} from "@react-aria/overlays";
@ -21,7 +21,7 @@ export function useAriaModalOverlay(
state: OverlayTriggerState,
ref: RefObject<HTMLElement>,
): ModalOverlayAria {
let {overlayProps, underlayProps} = useOverlay(
let {overlayProps, underlayProps} = useAriaOverlay(
{
...props,
isOpen: state.isOpen,

View File

@ -112,6 +112,12 @@ export function useMultiSelect<T>(
typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
delete typeSelectProps.onKeyDownCapture;
menuTriggerProps.onPressStart = (e) => {
if (e.pointerType !== "touch" && e.pointerType !== "keyboard" && !isDisabled) {
state.toggle(e.pointerType === "virtual" ? "first" : null);
}
};
const domProps = filterDOMProps(props, {labelable: true});
const triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps);

View File

@ -0,0 +1,24 @@
# @nextui-org/use-aria-overlay
A Quick description of the component
> This is an internal utility, not intended for public usage.
## Installation
```sh
yarn add @nextui-org/use-aria-overlay
# or
npm i @nextui-org/use-aria-overlay
```
## Contribution
Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.
## License
This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).

View File

@ -0,0 +1,60 @@
{
"name": "@nextui-org/use-aria-overlay",
"version": "2.0.0",
"description": "A custom implementation of react aria overlay",
"keywords": [
"use-aria-overlay"
],
"author": "Junior Garcia <jrgarciadev@gmail.com>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/hooks/use-aria-overlay"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "pnpm build:fast --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"dependencies": {
"@react-aria/focus": "3.17.1",
"@react-aria/interactions": "3.21.3",
"@react-aria/overlays": "3.22.1",
"@react-types/shared": "3.23.1"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"clean-package": "2.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}

View File

@ -0,0 +1,151 @@
import type {AriaOverlayProps, OverlayAria} from "@react-aria/overlays";
import type {RefObject} from "react";
import type React from "react";
import {isElementInChildOfActiveScope} from "@react-aria/focus";
import {useFocusWithin, useInteractOutside} from "@react-aria/interactions";
import {useEffect} from "react";
export interface UseAriaOverlayProps extends AriaOverlayProps {
/**
* When `true`, `click/focus` interactions will be disabled on elements outside
* the `Overlay`. Users need to click twice on outside elements to interact with them:
* once to close the overlay, and again to trigger the element.
*
* @default true
*/
disableOutsideEvents?: boolean;
}
const visibleOverlays: RefObject<Element | null>[] = [];
/**
* Provides the behavior for overlays such as dialogs, popovers, and menus.
* Hides the overlay when the user interacts outside it, when the Escape key is pressed,
* or optionally, on blur. Only the top-most overlay will close at once.
*/
export function useAriaOverlay(props: UseAriaOverlayProps, ref: RefObject<Element>): OverlayAria {
const {
disableOutsideEvents = true,
isDismissable = false,
isKeyboardDismissDisabled = false,
isOpen,
onClose,
shouldCloseOnBlur,
shouldCloseOnInteractOutside,
} = props;
// Add the overlay ref to the stack of visible overlays on mount, and remove on unmount.
useEffect(() => {
if (isOpen) {
visibleOverlays.push(ref);
}
return () => {
const index = visibleOverlays.indexOf(ref);
if (index >= 0) {
visibleOverlays.splice(index, 1);
}
};
}, [isOpen, ref]);
// Only hide the overlay when it is the topmost visible overlay in the stack
const onHide = () => {
if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) {
onClose();
}
};
const onInteractOutsideStart = (e: PointerEvent) => {
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
if (visibleOverlays[visibleOverlays.length - 1] === ref) {
if (disableOutsideEvents) {
e.stopPropagation();
e.preventDefault();
}
}
// For consistency with React Aria, toggle the combobox/menu on mouse down, but touch up.
if (e.pointerType !== "touch") {
onHide();
}
}
};
const onInteractOutside = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
if (visibleOverlays[visibleOverlays.length - 1] === ref) {
if (disableOutsideEvents) {
e.stopPropagation();
e.preventDefault();
}
}
onHide();
}
};
// Handle the escape key
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape" && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) {
e.stopPropagation();
e.preventDefault();
onHide();
}
};
// Handle clicking outside the overlay to close it
useInteractOutside({
isDisabled: !(isDismissable && isOpen),
onInteractOutside,
onInteractOutsideStart,
ref,
});
const {focusWithinProps} = useFocusWithin({
isDisabled: !shouldCloseOnBlur,
onBlurWithin: (e) => {
// Do not close if relatedTarget is null, which means focus is lost to the body.
// That can happen when switching tabs, or due to a VoiceOver/Chrome bug with Control+Option+Arrow navigation.
// Clicking on the body to close the overlay should already be handled by useInteractOutside.
// https://github.com/adobe/react-spectrum/issues/4130
// https://github.com/adobe/react-spectrum/issues/4922
//
// If focus is moving into a child focus scope (e.g. menu inside a dialog),
// do not close the outer overlay. At this point, the active scope should
// still be the outer overlay, since blur events run before focus.
if (!e.relatedTarget || isElementInChildOfActiveScope(e.relatedTarget)) {
return;
}
if (
!shouldCloseOnInteractOutside ||
shouldCloseOnInteractOutside(e.relatedTarget as Element)
) {
onHide();
}
},
});
const onPointerDownUnderlay = (e: React.PointerEvent) => {
// fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846
if (e.target === e.currentTarget) {
e.preventDefault();
}
};
return {
overlayProps: {
onKeyDown,
...focusWithinProps,
},
underlayProps: {
onPointerDown: onPointerDownUnderlay,
},
};
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src", "index.ts"]
}

View File

@ -7,7 +7,6 @@ export {isNonContiguousSelectionModifier, isCtrlKeyPressed} from "./utils";
export {
ariaHideOutside,
ariaShouldCloseOnInteractOutside,
getTransformOrigins,
toReactAriaPlacement,
toOverlayPlacement,

View File

@ -1,45 +0,0 @@
import {RefObject} from "react";
/**
* Used to handle the outside interaction for popover-based components
* e.g. dropdown, datepicker, date-range-picker, popover, select, autocomplete etc
* @param element - the element outside of the popover ref, originally from `shouldCloseOnInteractOutside`
* @param triggerRef - The trigger ref object
* @param state - The state from the popover component
* @returns - a boolean value which is same as shouldCloseOnInteractOutside
*/
export const ariaShouldCloseOnInteractOutside = (
element: Element,
triggerRef: RefObject<Element>,
state: any,
) => {
const trigger = triggerRef?.current;
if (!trigger || !trigger.contains(element)) {
// if there is focus scope block, there will be a pair of span[data-focus-scope-start] and span[data-focus-scope-end]
// the element with focus trap resides inbetween these two blocks
// we push all the elements in focus scope to `focusScopeElements`
const startElements = document.querySelectorAll("body > span[data-focus-scope-start]");
let focusScopeElements: Element[] = [];
startElements.forEach((startElement) => {
focusScopeElements.push(startElement.nextElementSibling!);
});
// if there is just one focusScopeElement, we close the state
// e.g. open a popover A -> click popover B
// then popover A should be closed and popover B should be open
// TODO: handle cases like modal > popover A -> click modal > popover B
// we should close the popover when it is the last opened
// however, currently ariaShouldCloseOnInteractOutside is called recursively
// and we need a way to check if there is something closed before that (i.e. nested elements)
// if so, popover shouldn't be closed in this case
if (focusScopeElements.length === 1) {
state.close();
return false;
}
}
return !trigger || !trigger.contains(element);
};

View File

@ -9,4 +9,3 @@ export {
} from "./utils";
export {ariaHideOutside} from "./ariaHideOutside";
export {ariaShouldCloseOnInteractOutside} from "./ariaShouldCloseOnInteractOutside";

51
pnpm-lock.yaml generated
View File

@ -686,9 +686,6 @@ importers:
packages/components/autocomplete:
dependencies:
'@nextui-org/aria-utils':
specifier: workspace:*
version: link:../../utilities/aria-utils
'@nextui-org/button':
specifier: workspace:*
version: link:../button
@ -1327,9 +1324,6 @@ importers:
'@internationalized/date':
specifier: ^3.5.4
version: 3.5.4
'@nextui-org/aria-utils':
specifier: workspace:*
version: link:../../utilities/aria-utils
'@nextui-org/button':
specifier: workspace:*
version: link:../button
@ -1428,9 +1422,6 @@ importers:
packages/components/dropdown:
dependencies:
'@nextui-org/aria-utils':
specifier: workspace:*
version: link:../../utilities/aria-utils
'@nextui-org/menu':
specifier: workspace:*
version: link:../menu
@ -2014,6 +2005,9 @@ importers:
'@nextui-org/use-aria-button':
specifier: workspace:*
version: link:../../hooks/use-aria-button
'@nextui-org/use-aria-overlay':
specifier: workspace:*
version: link:../../hooks/use-aria-overlay
'@nextui-org/use-safe-layout-effect':
specifier: workspace:*
version: link:../../hooks/use-safe-layout-effect
@ -2226,9 +2220,6 @@ importers:
packages/components/select:
dependencies:
'@nextui-org/aria-utils':
specifier: workspace:*
version: link:../../utilities/aria-utils
'@nextui-org/listbox':
specifier: workspace:*
version: link:../listbox
@ -3254,6 +3245,9 @@ importers:
packages/hooks/use-aria-modal-overlay:
dependencies:
'@nextui-org/use-aria-overlay':
specifier: workspace:*
version: link:../use-aria-overlay
'@react-aria/overlays':
specifier: 3.22.1
version: 3.22.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -3332,6 +3326,31 @@ importers:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
packages/hooks/use-aria-overlay:
dependencies:
'@react-aria/focus':
specifier: 3.17.1
version: 3.17.1(react@18.2.0)
'@react-aria/interactions':
specifier: 3.21.3
version: 3.21.3(react@18.2.0)
'@react-aria/overlays':
specifier: 3.22.1
version: 3.22.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-types/shared':
specifier: 3.23.1
version: 3.23.1(react@18.2.0)
devDependencies:
clean-package:
specifier: 2.2.0
version: 2.2.0
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
packages/hooks/use-aria-toggle-button:
dependencies:
'@nextui-org/use-aria-button':
@ -18068,13 +18087,15 @@ snapshots:
transitivePeerDependencies:
- '@parcel/core'
'@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))':
'@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)':
dependencies:
'@parcel/core': 2.12.0(@swc/helpers@0.5.9)
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)
'@parcel/logger': 2.12.0
'@parcel/utils': 2.12.0
lmdb: 2.8.5
transitivePeerDependencies:
- '@swc/helpers'
'@parcel/codeframe@2.12.0':
dependencies:
@ -18134,7 +18155,7 @@ snapshots:
'@parcel/core@2.12.0(@swc/helpers@0.5.9)':
dependencies:
'@mischnic/json-sourcemap': 0.1.1
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)
'@parcel/diagnostic': 2.12.0
'@parcel/events': 2.12.0
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)
@ -18549,7 +18570,7 @@ snapshots:
'@parcel/types@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)':
dependencies:
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)
'@parcel/diagnostic': 2.12.0
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)
'@parcel/package-manager': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.9))(@swc/helpers@0.5.9)