mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
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:
parent
81da063d6a
commit
123b7fbc9f
14
.changeset/happy-socks-hug.md
Normal file
14
.changeset/happy-socks-hug.md
Normal 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.
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
},
|
||||
[
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
24
packages/hooks/use-aria-overlay/README.md
Normal file
24
packages/hooks/use-aria-overlay/README.md
Normal 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).
|
||||
60
packages/hooks/use-aria-overlay/package.json
Normal file
60
packages/hooks/use-aria-overlay/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
151
packages/hooks/use-aria-overlay/src/index.ts
Normal file
151
packages/hooks/use-aria-overlay/src/index.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
4
packages/hooks/use-aria-overlay/tsconfig.json
Normal file
4
packages/hooks/use-aria-overlay/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src", "index.ts"]
|
||||
}
|
||||
@ -7,7 +7,6 @@ export {isNonContiguousSelectionModifier, isCtrlKeyPressed} from "./utils";
|
||||
|
||||
export {
|
||||
ariaHideOutside,
|
||||
ariaShouldCloseOnInteractOutside,
|
||||
getTransformOrigins,
|
||||
toReactAriaPlacement,
|
||||
toOverlayPlacement,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -9,4 +9,3 @@ export {
|
||||
} from "./utils";
|
||||
|
||||
export {ariaHideOutside} from "./ariaHideOutside";
|
||||
export {ariaShouldCloseOnInteractOutside} from "./ariaShouldCloseOnInteractOutside";
|
||||
|
||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user