mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
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:
parent
540aa2124b
commit
3b14c21e02
11
.changeset/good-crabs-clap.md
Normal file
11
.changeset/good-crabs-clap.md
Normal 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)
|
||||
@ -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", () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = {},
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
},
|
||||
[
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -7,6 +7,7 @@ export {isNonContiguousSelectionModifier, isCtrlKeyPressed} from "./utils";
|
||||
|
||||
export {
|
||||
ariaHideOutside,
|
||||
ariaShouldCloseOnInteractOutside,
|
||||
getTransformOrigins,
|
||||
toReactAriaPlacement,
|
||||
toOverlayPlacement,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -9,3 +9,4 @@ export {
|
||||
} from "./utils";
|
||||
|
||||
export {ariaHideOutside} from "./ariaHideOutside";
|
||||
export {ariaShouldCloseOnInteractOutside} from "./ariaShouldCloseOnInteractOutside";
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user