diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md new file mode 100644 index 0000000000..6ab95e745e --- /dev/null +++ b/.changeset/good-crabs-clap.md @@ -0,0 +1,11 @@ +--- +"@nextui-org/autocomplete": patch +"@nextui-org/modal": patch +"@nextui-org/popover": patch +"@nextui-org/dropdown": patch +"@nextui-org/select": patch +"@nextui-org/date-picker": patch +"@nextui-org/aria-utils": patch +--- + +Revise popover-based focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974, #1920, #1287, #3060) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 0fda8e0010..4574ae88de 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -140,7 +140,136 @@ describe("Autocomplete", () => { expect(() => wrapper.unmount()).not.toThrow(); }); - it("should close dropdown when clicking outside autocomplete", async () => { + it("should focus when clicking autocomplete", async () => { + const wrapper = render( + + + Penguin + + + Zebra + + + Shark + + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete"); + + // open the select listbox + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete listbox is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + }); + + it("should clear value after clicking clear button", async () => { + const wrapper = render( + + + Penguin + + + Zebra + + + Shark + + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete"); + + // open the select listbox + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete listbox is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + let options = wrapper.getAllByRole("option"); + + // select the target item + await act(async () => { + await userEvent.click(options[0]); + }); + + const {container} = wrapper; + + const clearButton = container.querySelector( + "[data-slot='inner-wrapper'] button:nth-of-type(1)", + )!; + + expect(clearButton).not.toBeNull(); + + // select the target item + await act(async () => { + await userEvent.click(clearButton); + }); + + // assert that the input has empty value + expect(autocomplete).toHaveValue(""); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + }); + + it("should open and close listbox by clicking selector button", async () => { + const wrapper = render( + + + Penguin + + + Zebra + + + Shark + + , + ); + + const {container} = wrapper; + + const selectorButton = container.querySelector( + "[data-slot='inner-wrapper'] button:nth-of-type(2)", + )!; + + expect(selectorButton).not.toBeNull(); + + const autocomplete = wrapper.getByTestId("autocomplete"); + + // open the select listbox by clicking selector button + await act(async () => { + await userEvent.click(selectorButton); + }); + + // assert that the autocomplete listbox is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + + // close the select listbox by clicking selector button again + await act(async () => { + await userEvent.click(selectorButton); + }); + + // assert that the autocomplete listbox is closed + expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that input is still focused + expect(autocomplete).toHaveFocus(); + }); + + it("should close listbox when clicking outside autocomplete", async () => { const wrapper = render( { const autocomplete = wrapper.getByTestId("close-when-clicking-outside-test"); - // open the select dropdown + // open the select listbox await act(async () => { await userEvent.click(autocomplete); }); - // assert that the autocomplete dropdown is open + // assert that the autocomplete listbox is open expect(autocomplete).toHaveAttribute("aria-expanded", "true"); // click outside the autocomplete component @@ -176,9 +305,12 @@ describe("Autocomplete", () => { // assert that the autocomplete is closed expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that input is not focused + expect(autocomplete).not.toHaveFocus(); }); - it("should close dropdown when clicking outside autocomplete with modal open", async () => { + it("should close listbox when clicking outside autocomplete with modal open", async () => { const wrapper = render( @@ -207,12 +339,12 @@ describe("Autocomplete", () => { const autocomplete = wrapper.getByTestId("close-when-clicking-outside-test"); - // open the autocomplete dropdown + // open the autocomplete listbox await act(async () => { await userEvent.click(autocomplete); }); - // assert that the autocomplete dropdown is open + // assert that the autocomplete listbox is open expect(autocomplete).toHaveAttribute("aria-expanded", "true"); // click outside the autocomplete component @@ -220,8 +352,133 @@ describe("Autocomplete", () => { await userEvent.click(document.body); }); - // assert that the autocomplete dropdown is closed + // assert that the autocomplete listbox is closed + expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that input is not focused + expect(autocomplete).not.toHaveFocus(); + }); + + it("should set the input after selection", async () => { + const wrapper = render( + + + Penguin + + + Zebra + + + Shark + + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete"); + + // open the listbox + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete listbox is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + + let options = wrapper.getAllByRole("option"); + + expect(options.length).toBe(3); + + // select the target item + await act(async () => { + await userEvent.click(options[0]); + }); + + // assert that the input has target selection + expect(autocomplete).toHaveValue("Penguin"); + }); + + it("should close listbox by clicking another autocomplete", async () => { + const wrapper = render( + <> + + + Penguin + + + Zebra + + + Shark + + + + + Penguin + + + Zebra + + + Shark + + + , + ); + + const {container} = wrapper; + + const autocomplete = wrapper.getByTestId("autocomplete"); + + const autocomplete2 = wrapper.getByTestId("autocomplete2"); + + const innerWrappers = container.querySelectorAll("[data-slot='inner-wrapper']"); + + const selectorButton = innerWrappers[0].querySelector("button:nth-of-type(2)")!; + + const selectorButton2 = innerWrappers[1].querySelector("button:nth-of-type(2)")!; + + expect(selectorButton).not.toBeNull(); + + expect(selectorButton2).not.toBeNull(); + + // open the select listbox by clicking selector button in the first autocomplete + await act(async () => { + await userEvent.click(selectorButton); + }); + + // assert that the first autocomplete listbox is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + + // close the select listbox by clicking the second autocomplete + await act(async () => { + await userEvent.click(selectorButton2); + }); + + // assert that the first autocomplete listbox is closed expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that the second autocomplete listbox is open + expect(autocomplete2).toHaveAttribute("aria-expanded", "true"); + + // assert that the first autocomplete is not focused + expect(autocomplete).not.toHaveFocus(); + + // assert that the second autocomplete is focused + expect(autocomplete2).toHaveFocus(); }); describe("validation", () => { diff --git a/packages/components/autocomplete/src/autocomplete.tsx b/packages/components/autocomplete/src/autocomplete.tsx index 022e42e2eb..2eadca4ebd 100644 --- a/packages/components/autocomplete/src/autocomplete.tsx +++ b/packages/components/autocomplete/src/autocomplete.tsx @@ -15,7 +15,6 @@ interface Props extends UseAutocompleteProps {} function Autocomplete(props: Props, ref: ForwardedRef) { const { Component, - state, isOpen, disableAnimation, selectorIcon = , @@ -33,7 +32,7 @@ function Autocomplete(props: Props, ref: ForwardedRef({...props, ref}); const popoverContent = isOpen ? ( - + diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 9c2c370dda..83e8a8caea 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -18,6 +18,7 @@ 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 extends Omit, keyof ComboBoxProps> { /** @@ -201,6 +202,9 @@ export function useAutocomplete(originalProps: UseAutocomplete const inputRef = useDOMRef(ref); const scrollShadowRef = useDOMRef(scrollRefProp); + // control the input focus behaviours internally + const shouldFocus = useRef(false); + const { buttonProps, inputProps, @@ -327,12 +331,14 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen]); - // unfocus the input when the popover closes & there's no selected item & no allows custom value + // react aria has different focus strategies internally + // hence, handle focus behaviours on our side for better flexibilty useEffect(() => { - if (!isOpen && !state.selectedItem && inputRef.current && !allowsCustomValue) { - inputRef.current.blur(); - } - }, [isOpen, allowsCustomValue]); + const action = shouldFocus.current || isOpen ? "focus" : "blur"; + + inputRef?.current?.[action](); + if (action === "blur") shouldFocus.current = false; + }, [shouldFocus.current, isOpen]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. @@ -365,6 +371,7 @@ export function useAutocomplete(originalProps: UseAutocomplete const onClear = useCallback(() => { state.setInputValue(""); state.setSelectedKey(null); + state.close(); }, [state]); const onFocus = useCallback( @@ -394,17 +401,20 @@ export function useAutocomplete(originalProps: UseAutocomplete const getClearButtonProps = () => ({ ...mergeProps(buttonProps, slotsProps.clearButtonProps), + // disable original focus and state toggle from react aria + onPressStart: () => {}, onPress: (e: PressEvent) => { slotsProps.clearButtonProps?.onPress?.(e); if (state.selectedItem) { onClear(); } else { - const inputFocused = inputRef.current === document.activeElement; - - allowsCustomValue && state.setInputValue(""); - !inputFocused && onFocus(true); + if (allowsCustomValue) { + state.setInputValue(""); + state.close(); + } } + inputRef?.current?.focus(); }, "data-visible": !!state.selectedItem || state.inputValue?.length > 0, className: slots.clearButton({ @@ -432,18 +442,19 @@ export function useAutocomplete(originalProps: UseAutocomplete ref: listBoxRef, ...mergeProps(slotsProps.listboxProps, listBoxProps, { shouldHighlightOnFocus: true, - shouldUseVirtualFocus: false, }), } as ListboxProps); const getPopoverProps = (props: DOMAttributes = {}) => { + const popoverProps = mergeProps(slotsProps.popoverProps, props); + return { state, ref: popoverRef, triggerRef: inputWrapperRef, scrollRef: listBoxRef, triggerType: "listbox", - ...mergeProps(slotsProps.popoverProps, props), + ...popoverProps, classNames: { content: slots.popoverContent({ class: clsx( @@ -453,6 +464,10 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => + ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, shouldFocus), } as unknown as PopoverProps; }; diff --git a/packages/components/date-picker/__tests__/date-picker.test.tsx b/packages/components/date-picker/__tests__/date-picker.test.tsx index d4d7c20971..f2e6f67ed6 100644 --- a/packages/components/date-picker/__tests__/date-picker.test.tsx +++ b/packages/components/date-picker/__tests__/date-picker.test.tsx @@ -458,4 +458,35 @@ describe("DatePicker", () => { expect(getTextValue(combobox)).toBe("2/4/2019"); // uncontrolled }); }); + + it("should close listbox by clicking another datepicker", async () => { + const {getByRole, getAllByRole} = render( + <> + + + , + ); + + const dateButtons = getAllByRole("button"); + + expect(dateButtons[0]).not.toBeNull(); + + expect(dateButtons[1]).not.toBeNull(); + + // open the datepicker dialog by clicking datepicker button in the first datepicker + triggerPress(dateButtons[0]); + + let dialog = getByRole("dialog"); + + // assert that the first datepicker dialog is open + expect(dialog).toBeVisible(); + + // close the datepicker dialog by clicking the second datepicker + triggerPress(dateButtons[1]); + + dialog = getByRole("dialog"); + + // assert that the second datepicker dialog is open + expect(dialog).toBeVisible(); + }); }); diff --git a/packages/components/date-picker/package.json b/packages/components/date-picker/package.json index 03a5798bfd..65a71d2c38 100644 --- a/packages/components/date-picker/package.json +++ b/packages/components/date-picker/package.json @@ -47,6 +47,7 @@ "@nextui-org/button": "workspace:*", "@nextui-org/date-input": "workspace:*", "@nextui-org/shared-icons": "workspace:*", + "@nextui-org/aria-utils": "workspace:*", "@react-stately/overlays": "3.6.5", "@react-stately/utils": "3.9.1", "@internationalized/date": "^3.5.2", diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index 8aa37d2ec0..8e2db325c6 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -15,6 +15,7 @@ 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"; @@ -173,6 +174,9 @@ export function useDatePicker({ ), }), }, + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state), }; }; diff --git a/packages/components/date-picker/src/use-date-range-picker.ts b/packages/components/date-picker/src/use-date-range-picker.ts index 334f213ff1..42df170d40 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -21,6 +21,7 @@ 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} from "@nextui-org/theme"; +import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils"; import {useDatePickerBase} from "./use-date-picker-base"; interface Props @@ -215,6 +216,9 @@ export function useDateRangePicker({ ), }), }, + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state), } as PopoverProps; }; diff --git a/packages/components/dropdown/__tests__/dropdown.test.tsx b/packages/components/dropdown/__tests__/dropdown.test.tsx index 2dffd2555f..ee1ada513d 100644 --- a/packages/components/dropdown/__tests__/dropdown.test.tsx +++ b/packages/components/dropdown/__tests__/dropdown.test.tsx @@ -538,6 +538,69 @@ describe("Dropdown", () => { spy.mockRestore(); }); + + it("should close listbox by clicking another dropdown", async () => { + const wrapper = render( + <> + + + + + + New file + Copy link + Edit file + + Delete file + + + + + + + + + New file + Copy link + Edit file + + Delete file + + + + , + ); + + const dropdown = wrapper.getByTestId("dropdown"); + + const dropdown2 = wrapper.getByTestId("dropdown2"); + + expect(dropdown).not.toBeNull(); + + expect(dropdown2).not.toBeNull(); + + // open the dropdown listbox by clicking dropdownor button in the first dropdown + await act(async () => { + await userEvent.click(dropdown); + }); + + // assert that the first dropdown listbox is open + expect(dropdown).toHaveAttribute("aria-expanded", "true"); + + // assert that the second dropdown listbox is close + expect(dropdown2).toHaveAttribute("aria-expanded", "false"); + + // close the dropdown listbox by clicking the second dropdown + await act(async () => { + await userEvent.click(dropdown2); + }); + + // assert that the first dropdown listbox is closed + expect(dropdown).toHaveAttribute("aria-expanded", "false"); + + // assert that the second dropdown listbox is open + expect(dropdown2).toHaveAttribute("aria-expanded", "true"); + }); }); describe("Keyboard interactions", () => { diff --git a/packages/components/dropdown/package.json b/packages/components/dropdown/package.json index 75346755cd..9149872306 100644 --- a/packages/components/dropdown/package.json +++ b/packages/components/dropdown/package.json @@ -45,6 +45,7 @@ "@nextui-org/popover": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", + "@nextui-org/aria-utils": "workspace:*", "@react-aria/menu": "3.13.1", "@react-aria/utils": "3.23.2", "@react-stately/menu": "3.6.1", diff --git a/packages/components/dropdown/src/use-dropdown.ts b/packages/components/dropdown/src/use-dropdown.ts index 08d74392a8..0124669b95 100644 --- a/packages/components/dropdown/src/use-dropdown.ts +++ b/packages/components/dropdown/src/use-dropdown.ts @@ -8,6 +8,7 @@ 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"; @@ -104,21 +105,28 @@ export function useDropdown(props: UseDropdownProps) { } }; - const getPopoverProps: PropGetter = (props = {}) => ({ - state, - placement, - ref: popoverRef, - disableAnimation, - shouldBlockScroll, - scrollRef: menuRef, - triggerRef: menuTriggerRef, - ...mergeProps(otherProps, props), - classNames: { - ...classNamesProp, - ...props.classNames, - content: clsx(classNames, classNamesProp?.content, props.className), - }, - }); + const getPopoverProps: PropGetter = (props = {}) => { + const popoverProps = mergeProps(otherProps, props); + + return { + state, + placement, + ref: popoverRef, + disableAnimation, + shouldBlockScroll, + scrollRef: menuRef, + triggerRef: menuTriggerRef, + ...popoverProps, + classNames: { + ...classNamesProp, + ...props.classNames, + content: clsx(classNames, classNamesProp?.content, props.className), + }, + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state), + }; + }; const getMenuTriggerProps: PropGetter = ( originalProps = {}, diff --git a/packages/components/modal/src/modal.tsx b/packages/components/modal/src/modal.tsx index 323aa53089..1aa2ceb64b 100644 --- a/packages/components/modal/src/modal.tsx +++ b/packages/components/modal/src/modal.tsx @@ -17,7 +17,11 @@ const Modal = forwardRef<"div", ModalProps>((props, ref) => { const {children, ...otherProps} = props; const context = useModal({...otherProps, ref}); - const overlay = {children}; + const overlay = ( + + {children} + + ); return ( diff --git a/packages/components/popover/__tests__/popover.test.tsx b/packages/components/popover/__tests__/popover.test.tsx index 5a09b2c8f6..2ab4e75da9 100644 --- a/packages/components/popover/__tests__/popover.test.tsx +++ b/packages/components/popover/__tests__/popover.test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import {render, fireEvent, act} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import {Button} from "@nextui-org/button"; import {Popover, PopoverContent, PopoverTrigger} from "../src"; @@ -159,4 +160,57 @@ describe("Popover", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + it("should close listbox by clicking another popover", async () => { + const wrapper = render( + <> + + + + + +

This is the content of the popover.

+
+
+ + + + + +

This is the content of the popover.

+
+
+ , + ); + + const popover = wrapper.getByTestId("popover"); + + const popover2 = wrapper.getByTestId("popover2"); + + expect(popover).not.toBeNull(); + + expect(popover2).not.toBeNull(); + + // open the popover by clicking popover in the first popover + await act(async () => { + await userEvent.click(popover); + }); + + // assert that the first popover is open + expect(popover).toHaveAttribute("aria-expanded", "true"); + + // assert that the second popover is close + expect(popover2).toHaveAttribute("aria-expanded", "false"); + + // close the popover by clicking the second popover + await act(async () => { + await userEvent.click(popover2); + }); + + // assert that the first popover is closed + expect(popover).toHaveAttribute("aria-expanded", "false"); + + // assert that the second popover is open + expect(popover2).toHaveAttribute("aria-expanded", "true"); + }); }); diff --git a/packages/components/popover/src/popover.tsx b/packages/components/popover/src/popover.tsx index 3465d884b3..c44888f487 100644 --- a/packages/components/popover/src/popover.tsx +++ b/packages/components/popover/src/popover.tsx @@ -20,7 +20,11 @@ const Popover = forwardRef<"div", PopoverProps>((props, ref) => { const [trigger, content] = Children.toArray(children); - const overlay = {content}; + const overlay = ( + + {content} + + ); return ( diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index 40a3937ea9..d1b3ea1a01 100644 --- a/packages/components/popover/src/use-aria-popover.ts +++ b/packages/components/popover/src/use-aria-popover.ts @@ -10,6 +10,7 @@ import {OverlayPlacement, ariaHideOutside, toReactAriaPlacement} from "@nextui-o import {OverlayTriggerState} from "@react-stately/overlays"; import {mergeProps} from "@react-aria/utils"; import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; +import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils"; export interface Props { /** @@ -64,7 +65,7 @@ export function useReactAriaPopover( ...otherProps } = props; - const isNonModal = isNonModalProp || true; + const isNonModal = isNonModalProp ?? true; const {overlayProps, underlayProps} = useOverlay( { @@ -75,12 +76,7 @@ export function useReactAriaPopover( isKeyboardDismissDisabled, shouldCloseOnInteractOutside: shouldCloseOnInteractOutside ? shouldCloseOnInteractOutside - : (element) => { - // Don't close if the click is within the trigger or the popover itself - let trigger = triggerRef?.current; - - return !trigger || !trigger.contains(element); - }, + : (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state), }, popoverRef, ); diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 06e248dc2c..fcea4294f0 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -447,6 +447,65 @@ describe("Select", () => { expect(displayedText).toBe("Penguin, Zebra"); }); + + it("should close listbox by clicking another select", async () => { + const wrapper = render( + <> + + + , + ); + + const select = wrapper.getByTestId("select"); + + const select2 = wrapper.getByTestId("select2"); + + expect(select).not.toBeNull(); + + expect(select2).not.toBeNull(); + + // open the select listbox by clicking selector button in the first select + await act(async () => { + await userEvent.click(select); + }); + + // assert that the first select listbox is open + expect(select).toHaveAttribute("aria-expanded", "true"); + + // assert that the second select listbox is close + expect(select2).toHaveAttribute("aria-expanded", "false"); + + // close the select listbox by clicking the second select + await act(async () => { + await userEvent.click(select2); + }); + + // assert that the first select listbox is closed + expect(select).toHaveAttribute("aria-expanded", "false"); + + // assert that the second select listbox is open + expect(select2).toHaveAttribute("aria-expanded", "true"); + }); }); describe("Select with React Hook Form", () => { diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index 925fb0abbf..0a92cab20d 100644 --- a/packages/components/select/src/select.tsx +++ b/packages/components/select/src/select.tsx @@ -105,13 +105,7 @@ function Select(props: Props, ref: ForwardedRef state.isOpen ? ( - + diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 6e1cf07d11..35600017ba 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -27,6 +27,7 @@ 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 = { @@ -500,6 +501,8 @@ export function useSelect(originalProps: UseSelectProps) { const getPopoverProps = useCallback( (props: DOMAttributes = {}) => { + const popoverProps = mergeProps(slotsProps.popoverProps, props); + return { state, triggerRef, @@ -512,12 +515,15 @@ export function useSelect(originalProps: UseSelectProps) { class: clsx(classNames?.popoverContent, props.className), }), }, - ...mergeProps(slotsProps.popoverProps, props), + ...popoverProps, offset: state.selectedItems && state.selectedItems.length > 0 ? // 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, triggerRef, state), } as PopoverProps; }, [ diff --git a/packages/utilities/aria-utils/package.json b/packages/utilities/aria-utils/package.json index 84f24c83d0..77068bc204 100644 --- a/packages/utilities/aria-utils/package.json +++ b/packages/utilities/aria-utils/package.json @@ -43,6 +43,7 @@ "@nextui-org/react-rsc-utils": "workspace:*", "@react-aria/utils": "3.23.2", "@react-stately/collections": "3.10.5", + "@react-stately/overlays": "3.6.5", "@react-types/overlays": "3.8.5", "@react-types/shared": "3.22.1" }, diff --git a/packages/utilities/aria-utils/src/index.ts b/packages/utilities/aria-utils/src/index.ts index 8e70c1b183..6b8d27ad3f 100644 --- a/packages/utilities/aria-utils/src/index.ts +++ b/packages/utilities/aria-utils/src/index.ts @@ -7,6 +7,7 @@ export {isNonContiguousSelectionModifier, isCtrlKeyPressed} from "./utils"; export { ariaHideOutside, + ariaShouldCloseOnInteractOutside, getTransformOrigins, toReactAriaPlacement, toOverlayPlacement, diff --git a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts new file mode 100644 index 0000000000..0be005b516 --- /dev/null +++ b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts @@ -0,0 +1,41 @@ +import {MutableRefObject, 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 ref - The popover ref object that will interact outside with + * @param state - The popover state from the target component + * @param shouldFocus - a mutable ref boolean object to control the focus state + * (used in input-based component such as autocomplete) + * @returns - a boolean value which is same as shouldCloseOnInteractOutside + */ +export const ariaShouldCloseOnInteractOutside = ( + element: Element, + ref: RefObject, + state: any, + shouldFocus?: MutableRefObject, +) => { + let trigger = ref?.current; + + // check if the click is on the underlay + const clickOnUnderlay = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the component + if (!trigger || !trigger.contains(element)) { + // blur the component (e.g. autocomplete) + if (shouldFocus) shouldFocus.current = false; + // if the click is not on the underlay, + // trigger the state close to prevent from opening multiple popovers at the same time + // e.g. open dropdown1 -> click dropdown2 (dropdown1 should be closed and dropdown2 should be open) + if (!clickOnUnderlay) state.close(); + } else { + // otherwise the component (e.g. autocomplete) should keep focused + if (shouldFocus) shouldFocus.current = true; + } + + // if the click is on the underlay, + // clicking the overlay should close the popover instead of closing the modal + // otherwise, allow interaction with other elements + return clickOnUnderlay; +}; diff --git a/packages/utilities/aria-utils/src/overlays/index.ts b/packages/utilities/aria-utils/src/overlays/index.ts index ccf839f2d9..6999e8b90c 100644 --- a/packages/utilities/aria-utils/src/overlays/index.ts +++ b/packages/utilities/aria-utils/src/overlays/index.ts @@ -9,3 +9,4 @@ export { } from "./utils"; export {ariaHideOutside} from "./ariaHideOutside"; +export {ariaShouldCloseOnInteractOutside} from "./ariaShouldCloseOnInteractOutside"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59796cc15f..8163b07a45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1327,6 +1327,9 @@ importers: '@internationalized/date': specifier: ^3.5.2 version: 3.5.2 + '@nextui-org/aria-utils': + specifier: workspace:* + version: link:../../utilities/aria-utils '@nextui-org/button': specifier: workspace:* version: link:../button @@ -1425,6 +1428,9 @@ importers: packages/components/dropdown: dependencies: + '@nextui-org/aria-utils': + specifier: workspace:* + version: link:../../utilities/aria-utils '@nextui-org/menu': specifier: workspace:* version: link:../menu @@ -3651,6 +3657,9 @@ importers: '@react-stately/collections': specifier: 3.10.5 version: 3.10.5(react@18.2.0) + '@react-stately/overlays': + specifier: 3.6.5 + version: 3.6.5(react@18.2.0) '@react-types/overlays': specifier: 3.8.5 version: 3.8.5(react@18.2.0)