fix: popover-based focus behaviour (#2854)

* fix(autocomplete): autocomplete focus behaviour

* feat(autocomplete): add test case for catching blur cases

* refactor(autocomplete): use isOpen instead

* feat(autocomplete): add "should focus when clicking autocomplete" test case

* feat(autocomplete): add should set the input after selection

* fix(autocomplete): remove shouldUseVirtualFocus

* fix(autocomplete): uncomment blur logic

* refactor(autocomplete): remove state as it is in getPopoverProps

* refactor(autocomplete): remove unnecessary blur

* refactor(select): remove unncessary props

* fix(popover): use domRef instead

* fix(popover): revise isNonModal and isDismissable

* fix(popover): use dialogRef back

* fix(popover): rollback

* fix(autocomplete): onFocus logic

* feat(popover): set disableFocusManagement to overlay

* feat(modal): set disableFocusManagement to overlay

* fix(autocomplete): set disableFocusManagement for autocomplete

* feat(popover): include disableFocusManagement prop

* refactor(autocomplete): revise type in selectorButton

* fix(autocomplete): revise focus logic

* feat(autocomplete): add internal focus state and add shouldCloseOnInteractOutside

* feat(autocomplete): handle selectedItem change

* feat(autocomplete): add clear button test

* feat(changeset): add changeset

* refactor(components): use the original order

* refactor(autocomplete): add more comments

* fix(autocomplete): revise focus behaviours

* refactor(autocomplete): rename to listbox

* chore(popover): remove disableFocusManagement from popover

* chore(autocomplete): remove disableFocusManagement from autocomplete

* chore(changeset): add issue number

* fix(popover): don't set default value to transformOrigin

* fix(autocomplete): revise shouldCloseOnInteractOutside logic

* feat(autocomplete): should close listbox by clicking another autocomplete

* fix(popover): add disableFocusManagement to overlay

* refactor(autocomplete): revise comments and refactor shouldCloseOnInteractOutside

* feat(changeset): add issue number

* fix(autocomplete): merge with selectorButtonProps.onClick

* refactor(autocomplete): remove extra line

* refactor(autocomplete): revise comment

* feat(select): add shouldCloseOnInteractOutside

* feat(dropdown): add shouldCloseOnInteractOutside

* feat(date-picker): add shouldCloseOnInteractOutside

* feat(changeset): add dropdown and date-picker

* fix(popover): revise shouldCloseOnInteractOutside

* feat(date-picker): integrate with ariaShouldCloseOnInteractOutside

* feat(select): integrate with ariaShouldCloseOnInteractOutside

* feat(dropdown): integrate with ariaShouldCloseOnInteractOutside

* feat(popover): integrate with ariaShouldCloseOnInteractOutside

* feat(aria-utils): ariaShouldCloseOnInteractOutside

* chore(deps): update pnpm-lock.yaml

* feat(autocomplete): integrate with ariaShouldCloseOnInteractOutside

* feat(aria-utils): handle setShouldFocus logic

* feat(changeset): add @nextui-org/aria-utils

* chore(autocomplete): put the test into correct group

* feat(select): should close listbox by clicking another select

* feat(dropdown): should close listbox by clicking another dropdown

* feat(popover): should close listbox by clicking another popover

* feat(date-picker): should close listbox by clicking another datepicker

* chore(changeset): add issue numbers and revise changeset message

* refactor(autocomplete): change to useRef instead

* refactor(autocomplete): change to useRef instead

* refactor(aria-utils): revise comments and format code

* chore(changeset): add issue number

* chore: take popoverProps.shouldCloseOnInteractOutside first

* refactor(autocomplete): remove unnecessary logic

* refactor(autocomplete): focus management logic
This commit is contained in:
աӄա 2024-05-25 03:19:46 +08:00 committed by GitHub
parent 540aa2124b
commit 3b14c21e02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 616 additions and 52 deletions

View File

@ -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)

View File

@ -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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);
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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);
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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);
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(
<Autocomplete
aria-label="Favorite Animal"
@ -161,12 +290,12 @@ describe("Autocomplete", () => {
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(
<Modal isOpen>
<ModalContent>
@ -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(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);
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(
<>
<Autocomplete
aria-label="Favorite Animal"
data-testid="autocomplete"
label="Favorite Animal"
>
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>
<Autocomplete
aria-label="Favorite Animal"
data-testid="autocomplete2"
label="Favorite Animal"
>
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>
</>,
);
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", () => {

View File

@ -15,7 +15,6 @@ interface Props<T> extends UseAutocompleteProps<T> {}
function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLInputElement>) {
const {
Component,
state,
isOpen,
disableAnimation,
selectorIcon = <ChevronDownIcon />,
@ -33,7 +32,7 @@ function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLI
} = useAutocomplete<T>({...props, ref});
const popoverContent = isOpen ? (
<FreeSoloPopover {...getPopoverProps()} state={state}>
<FreeSoloPopover {...getPopoverProps()}>
<ScrollShadow {...getListBoxWrapperProps()}>
<Listbox {...getListBoxProps()} />
</ScrollShadow>

View File

@ -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<T> extends Omit<HTMLNextUIProps<"input">, keyof ComboBoxProps<T>> {
/**
@ -201,6 +202,9 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
const inputRef = useDOMRef<HTMLInputElement>(ref);
const scrollShadowRef = useDOMRef<HTMLElement>(scrollRefProp);
// control the input focus behaviours internally
const shouldFocus = useRef(false);
const {
buttonProps,
inputProps,
@ -327,12 +331,14 @@ export function useAutocomplete<T extends object>(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<T extends object>(originalProps: UseAutocomplete
const onClear = useCallback(() => {
state.setInputValue("");
state.setSelectedKey(null);
state.close();
}, [state]);
const onFocus = useCallback(
@ -394,17 +401,20 @@ export function useAutocomplete<T extends object>(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<T extends object>(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<T extends object>(originalProps: UseAutocomplete
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) =>
ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, shouldFocus),
} as unknown as PopoverProps;
};

View File

@ -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(
<>
<DatePicker data-testid="datepicker" label="Date" />
<DatePicker data-testid="datepicker2" label="Date" />
</>,
);
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();
});
});

View File

@ -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",

View File

@ -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<T extends DateValue>({
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state),
};
};

View File

@ -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<T extends DateValue>
@ -215,6 +216,9 @@ export function useDateRangePicker<T extends DateValue>({
),
}),
},
shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside
? popoverProps.shouldCloseOnInteractOutside
: (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state),
} as PopoverProps;
};

View File

@ -538,6 +538,69 @@ describe("Dropdown", () => {
spy.mockRestore();
});
it("should close listbox by clicking another dropdown", async () => {
const wrapper = render(
<>
<Dropdown>
<DropdownTrigger>
<Button data-testid="dropdown">Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" color="danger">
Delete file
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button data-testid="dropdown2">Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" color="danger">
Delete file
</DropdownItem>
</DropdownMenu>
</Dropdown>
</>,
);
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", () => {

View File

@ -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",

View File

@ -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 = {},

View File

@ -17,7 +17,11 @@ const Modal = forwardRef<"div", ModalProps>((props, ref) => {
const {children, ...otherProps} = props;
const context = useModal({...otherProps, ref});
const overlay = <Overlay portalContainer={context.portalContainer}>{children}</Overlay>;
const overlay = (
<Overlay disableFocusManagement portalContainer={context.portalContainer}>
{children}
</Overlay>
);
return (
<ModalProvider value={context}>

View File

@ -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(
<>
<Popover>
<PopoverTrigger>
<button data-testid="popover">Open popover</button>
</PopoverTrigger>
<PopoverContent>
<p>This is the content of the popover.</p>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger>
<button data-testid="popover2">Open popover</button>
</PopoverTrigger>
<PopoverContent>
<p>This is the content of the popover.</p>
</PopoverContent>
</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");
});
});

View File

@ -20,7 +20,11 @@ const Popover = forwardRef<"div", PopoverProps>((props, ref) => {
const [trigger, content] = Children.toArray(children);
const overlay = <Overlay portalContainer={context.portalContainer}>{content}</Overlay>;
const overlay = (
<Overlay disableFocusManagement portalContainer={context.portalContainer}>
{content}
</Overlay>
);
return (
<PopoverProvider value={context}>

View File

@ -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,
);

View File

@ -447,6 +447,65 @@ describe("Select", () => {
expect(displayedText).toBe("Penguin, Zebra");
});
it("should close listbox by clicking another select", async () => {
const wrapper = render(
<>
<Select aria-label="Favorite Animal" data-testid="select" label="Favorite Animal">
<SelectItem key="penguin" value="penguin">
Penguin
</SelectItem>
<SelectItem key="zebra" value="zebra">
Zebra
</SelectItem>
<SelectItem key="shark" value="shark">
Shark
</SelectItem>
</Select>
<Select aria-label="Favorite Animal" data-testid="select2" label="Favorite Animal">
<SelectItem key="penguin" value="penguin">
Penguin
</SelectItem>
<SelectItem key="zebra" value="zebra">
Zebra
</SelectItem>
<SelectItem key="shark" value="shark">
Shark
</SelectItem>
</Select>
</>,
);
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", () => {

View File

@ -105,13 +105,7 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE
const popoverContent = useMemo(
() =>
state.isOpen ? (
<FreeSoloPopover
{...getPopoverProps()}
// avoid closing the popover when navigating with the keyboard
shouldCloseOnInteractOutside={undefined}
state={state}
triggerRef={triggerRef}
>
<FreeSoloPopover {...getPopoverProps()}>
<ScrollShadow {...getListboxWrapperProps()}>
<Listbox {...getListboxProps()} />
</ScrollShadow>

View File

@ -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<T = object> = {
@ -500,6 +501,8 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
const getPopoverProps = useCallback(
(props: DOMAttributes = {}) => {
const popoverProps = mergeProps(slotsProps.popoverProps, props);
return {
state,
triggerRef,
@ -512,12 +515,15 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
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;
},
[

View File

@ -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"
},

View File

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

View File

@ -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<Element>,
state: any,
shouldFocus?: MutableRefObject<boolean>,
) => {
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;
};

View File

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

9
pnpm-lock.yaml generated
View File

@ -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)