From 103663b1661fab14d1fff7b9f1206aaadf22c512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Wed, 24 Apr 2024 13:18:52 +0800 Subject: [PATCH 01/68] fix(autocomplete): autocomplete focus behaviour --- .../components/autocomplete/src/use-autocomplete.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 33ee107577..5ba0b1e53f 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -325,12 +325,14 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen]); - // unfocus the input when the popover closes & there's no selected item & no allows custom value useEffect(() => { - if (!isOpen && !state.selectedItem && inputRef.current && !allowsCustomValue) { - inputRef.current.blur(); + if (state.isOpen) { + onFocus(true); + } else { + // TODO(FIXME): autocomplete within modal will block combobox closing + // inputRef.current?.blur(); } - }, [isOpen, allowsCustomValue]); + }, [state.isOpen]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. @@ -363,6 +365,7 @@ export function useAutocomplete(originalProps: UseAutocomplete const onClear = useCallback(() => { state.setInputValue(""); state.setSelectedKey(null); + state.close(); }, [state]); const onFocus = useCallback( From 6700c49516cdaf1cfd52f9070e5485fef6fe7e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Wed, 24 Apr 2024 13:32:18 +0800 Subject: [PATCH 02/68] feat(autocomplete): add test case for catching blur cases --- .../components/autocomplete/__tests__/autocomplete.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 1c4c47103f..143aa2f3d3 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -172,6 +172,9 @@ 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 () => { @@ -218,5 +221,8 @@ describe("Autocomplete", () => { // assert that the autocomplete dropdown is closed expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that input is not focused + expect(autocomplete).not.toHaveFocus(); }); }); From f8701dff972d7d1afe1d8fb70c855c8799f24155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Wed, 24 Apr 2024 13:57:42 +0800 Subject: [PATCH 03/68] refactor(autocomplete): use isOpen instead --- packages/components/autocomplete/src/use-autocomplete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 5ba0b1e53f..20f75c9485 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -326,13 +326,13 @@ export function useAutocomplete(originalProps: UseAutocomplete }, [isOpen]); useEffect(() => { - if (state.isOpen) { + if (isOpen) { onFocus(true); } else { // TODO(FIXME): autocomplete within modal will block combobox closing // inputRef.current?.blur(); } - }, [state.isOpen]); + }, [isOpen]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. From bdb41e02ee4b36bf5c916a29162fe5eaa3bf0e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Wed, 24 Apr 2024 13:57:57 +0800 Subject: [PATCH 04/68] feat(autocomplete): add "should focus when clicking autocomplete" test case --- .../__tests__/autocomplete.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 143aa2f3d3..48cc596eee 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -136,6 +136,83 @@ describe("Autocomplete", () => { expect(() => wrapper.unmount()).not.toThrow(); }); + it("should focus when clicking autocomplete", async () => { + const wrapper = render( + + + Penguin + + + Zebra + + + Shark + + , + ); + + const autocomplete = wrapper.getByTestId("autocomplete"); + + // open the select dropdown + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete dropdown is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + }); + + it("should open and close dropdown 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 dropdown by clicking selector button + await act(async () => { + await userEvent.click(selectorButton); + }); + + // assert that the autocomplete dropdown is open + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + // assert that input is focused + expect(autocomplete).toHaveFocus(); + + // close the select dropdown by clicking selector button again + await act(async () => { + await userEvent.click(selectorButton); + }); + + // assert that the autocomplete dropdown is closed + expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + + // assert that input is still focused + expect(autocomplete).toHaveFocus(); + }); + it("should close dropdown when clicking outside autocomplete", async () => { const wrapper = render( Date: Wed, 24 Apr 2024 15:01:14 +0800 Subject: [PATCH 05/68] feat(autocomplete): add should set the input after selection --- .../__tests__/autocomplete.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 48cc596eee..4b7b224e48 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -302,4 +302,45 @@ describe("Autocomplete", () => { // 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 select dropdown + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete dropdown 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"); + }); }); From 3e13a3e471678a84218c144c5c0d1980707d9021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Thu, 25 Apr 2024 15:31:44 +0800 Subject: [PATCH 06/68] fix(autocomplete): remove shouldUseVirtualFocus --- packages/components/autocomplete/src/use-autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 20f75c9485..bb97d93db0 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -432,7 +432,6 @@ export function useAutocomplete(originalProps: UseAutocomplete ref: listBoxRef, ...mergeProps(slotsProps.listboxProps, listBoxProps, { shouldHighlightOnFocus: true, - shouldUseVirtualFocus: false, }), } as ListboxProps); From 7eb5a2fe47e92faea540c7d541f5acebec1d82f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Thu, 25 Apr 2024 15:33:17 +0800 Subject: [PATCH 07/68] fix(autocomplete): uncomment blur logic --- packages/components/autocomplete/src/use-autocomplete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index bb97d93db0..f6eaa64e36 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -330,7 +330,7 @@ export function useAutocomplete(originalProps: UseAutocomplete onFocus(true); } else { // TODO(FIXME): autocomplete within modal will block combobox closing - // inputRef.current?.blur(); + inputRef.current?.blur(); } }, [isOpen]); From 706186a39fb9526336179818b26ddd7576105780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 6 May 2024 22:50:21 +0800 Subject: [PATCH 08/68] refactor(autocomplete): remove state as it is in getPopoverProps --- packages/components/autocomplete/src/autocomplete.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/components/autocomplete/src/autocomplete.tsx b/packages/components/autocomplete/src/autocomplete.tsx index 7da035b20f..25fec01c6b 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 ? ( - + From 3f06b1d4b940ff61f5d9514dbead8c39bc31c39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 6 May 2024 22:52:06 +0800 Subject: [PATCH 09/68] refactor(autocomplete): remove unnecessary blur --- packages/components/autocomplete/src/use-autocomplete.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index f6eaa64e36..573b659d9d 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -328,9 +328,6 @@ export function useAutocomplete(originalProps: UseAutocomplete useEffect(() => { if (isOpen) { onFocus(true); - } else { - // TODO(FIXME): autocomplete within modal will block combobox closing - inputRef.current?.blur(); } }, [isOpen]); From f7cba0cff2f1c0b1cc33d943237866c54d5101b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 6 May 2024 23:10:49 +0800 Subject: [PATCH 10/68] refactor(select): remove unncessary props --- packages/components/select/src/select.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index bda7b15277..945d6c0f4b 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 ? ( - + From 90e6897c139fd63319ef87af44cde196a9dbd712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 6 May 2024 23:11:31 +0800 Subject: [PATCH 11/68] fix(popover): use domRef instead --- packages/components/popover/src/use-popover.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/components/popover/src/use-popover.ts b/packages/components/popover/src/use-popover.ts index 5e8fa86c52..08502a420b 100644 --- a/packages/components/popover/src/use-popover.ts +++ b/packages/components/popover/src/use-popover.ts @@ -121,10 +121,8 @@ export function usePopover(originalProps: UsePopoverProps) { const Component = as || "div"; const domRef = useDOMRef(ref); - const domTriggerRef = useRef(null); const wasTriggerPressedRef = useRef(false); - const dialogRef = useRef(null); const triggerRef = triggerRefProp || domTriggerRef; const disableAnimation = originalProps.disableAnimation ?? false; @@ -171,7 +169,7 @@ export function usePopover(originalProps: UsePopoverProps) { const {isFocusVisible, isFocused, focusProps} = useFocusRing(); - const {dialogProps, titleProps} = useDialog({}, dialogRef); + const {dialogProps, titleProps} = useDialog({}, domRef); const slots = useMemo( () => @@ -190,7 +188,7 @@ export function usePopover(originalProps: UsePopoverProps) { }); const getDialogProps: PropGetter = (props = {}) => ({ - ref: dialogRef, + ref: domRef, "data-slot": "base", "data-open": dataAttr(state.isOpen), "data-focus": dataAttr(isFocused), From 697a797454f8568f749878425cf7205e58b81c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 6 May 2024 23:12:33 +0800 Subject: [PATCH 12/68] fix(popover): revise isNonModal and isDismissable --- packages/components/popover/src/use-aria-popover.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index 40a3937ea9..75139e8a1d 100644 --- a/packages/components/popover/src/use-aria-popover.ts +++ b/packages/components/popover/src/use-aria-popover.ts @@ -53,7 +53,6 @@ export function useReactAriaPopover( scrollRef, shouldFlip, boundaryElement, - isDismissable = true, shouldCloseOnBlur = true, placement: placementProp = "top", containerPadding, @@ -64,14 +63,14 @@ export function useReactAriaPopover( ...otherProps } = props; - const isNonModal = isNonModalProp || true; + const isNonModal = isNonModalProp ?? true; const {overlayProps, underlayProps} = useOverlay( { isOpen: state.isOpen, onClose: state.close, shouldCloseOnBlur, - isDismissable, + isDismissable: !isNonModal, isKeyboardDismissDisabled, shouldCloseOnInteractOutside: shouldCloseOnInteractOutside ? shouldCloseOnInteractOutside From 15873dadf89b291688ea267eb4fc7d1dbee16e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Tue, 7 May 2024 00:55:02 +0800 Subject: [PATCH 13/68] fix(popover): use dialogRef back --- packages/components/popover/src/use-popover.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/components/popover/src/use-popover.ts b/packages/components/popover/src/use-popover.ts index 08502a420b..5e8fa86c52 100644 --- a/packages/components/popover/src/use-popover.ts +++ b/packages/components/popover/src/use-popover.ts @@ -121,8 +121,10 @@ export function usePopover(originalProps: UsePopoverProps) { const Component = as || "div"; const domRef = useDOMRef(ref); + const domTriggerRef = useRef(null); const wasTriggerPressedRef = useRef(false); + const dialogRef = useRef(null); const triggerRef = triggerRefProp || domTriggerRef; const disableAnimation = originalProps.disableAnimation ?? false; @@ -169,7 +171,7 @@ export function usePopover(originalProps: UsePopoverProps) { const {isFocusVisible, isFocused, focusProps} = useFocusRing(); - const {dialogProps, titleProps} = useDialog({}, domRef); + const {dialogProps, titleProps} = useDialog({}, dialogRef); const slots = useMemo( () => @@ -188,7 +190,7 @@ export function usePopover(originalProps: UsePopoverProps) { }); const getDialogProps: PropGetter = (props = {}) => ({ - ref: domRef, + ref: dialogRef, "data-slot": "base", "data-open": dataAttr(state.isOpen), "data-focus": dataAttr(isFocused), From af3876d9dde7f30f4c86bc2091166435f046dd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Tue, 7 May 2024 01:05:57 +0800 Subject: [PATCH 14/68] fix(popover): rollback --- packages/components/popover/src/use-aria-popover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index 75139e8a1d..0608abf758 100644 --- a/packages/components/popover/src/use-aria-popover.ts +++ b/packages/components/popover/src/use-aria-popover.ts @@ -54,6 +54,7 @@ export function useReactAriaPopover( shouldFlip, boundaryElement, shouldCloseOnBlur = true, + isDismissable = true, placement: placementProp = "top", containerPadding, shouldCloseOnInteractOutside, @@ -70,7 +71,7 @@ export function useReactAriaPopover( isOpen: state.isOpen, onClose: state.close, shouldCloseOnBlur, - isDismissable: !isNonModal, + isDismissable, isKeyboardDismissDisabled, shouldCloseOnInteractOutside: shouldCloseOnInteractOutside ? shouldCloseOnInteractOutside From 16e1271705193fd88ec0a60ad26847e49da2feec Mon Sep 17 00:00:00 2001 From: WK Wong Date: Tue, 7 May 2024 18:30:17 +0800 Subject: [PATCH 15/68] fix(autocomplete): onFocus logic --- packages/components/autocomplete/src/use-autocomplete.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 573b659d9d..f0d66eca17 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -326,9 +326,7 @@ export function useAutocomplete(originalProps: UseAutocomplete }, [isOpen]); useEffect(() => { - if (isOpen) { - onFocus(true); - } + onFocus(isOpen); }, [isOpen]); // to prevent the error message: @@ -367,7 +365,8 @@ export function useAutocomplete(originalProps: UseAutocomplete const onFocus = useCallback( (isFocused: boolean) => { - inputRef.current?.focus(); + if (isFocused) inputRef.current?.focus(); + else inputRef.current?.blur(); state.setFocused(isFocused); }, [state, inputRef], From 8146b4d64ac63d8c9e0cbab226a8238117a5c19e Mon Sep 17 00:00:00 2001 From: WK Wong Date: Tue, 7 May 2024 18:30:36 +0800 Subject: [PATCH 16/68] feat(popover): set disableFocusManagement to overlay --- packages/components/popover/src/free-solo-popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index edab8f19ec..87bab1ee94 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -129,7 +129,7 @@ const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( }, [backdrop, disableAnimation, getBackdropProps]); return ( - + {!isNonModal && backdropContent} Date: Tue, 7 May 2024 18:30:47 +0800 Subject: [PATCH 17/68] feat(modal): set disableFocusManagement to overlay --- packages/components/modal/src/modal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 ( From 4b1a3e4a94635b26fb0815b105d28b4b43b8fd5b Mon Sep 17 00:00:00 2001 From: WK Wong Date: Tue, 7 May 2024 18:55:10 +0800 Subject: [PATCH 18/68] fix(autocomplete): set disableFocusManagement for autocomplete --- packages/components/autocomplete/src/use-autocomplete.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index f0d66eca17..446a629b0d 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -448,6 +448,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, + disableFocusManagement: true, } as unknown as PopoverProps; }; From 86133a3866672313f97cdb7797fec479893ae267 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Tue, 7 May 2024 18:55:31 +0800 Subject: [PATCH 19/68] feat(popover): include disableFocusManagement prop --- packages/components/popover/src/free-solo-popover.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index 87bab1ee94..7f7bd9a93a 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -23,6 +23,7 @@ export interface FreeSoloPopoverProps extends Omit originX?: number; originY?: number; }; + disableFocusManagement?: boolean; } type FreeSoloPopoverWrapperProps = { @@ -86,7 +87,7 @@ const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( FreeSoloPopoverWrapper.displayName = "NextUI.FreeSoloPopoverWrapper"; const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( - ({children, transformOrigin, ...props}, ref) => { + ({children, transformOrigin, disableFocusManagement = false, ...props}, ref) => { const { Component, state, @@ -129,7 +130,7 @@ const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( }, [backdrop, disableAnimation, getBackdropProps]); return ( - + {!isNonModal && backdropContent} Date: Tue, 7 May 2024 19:21:03 +0800 Subject: [PATCH 20/68] refactor(autocomplete): revise type in selectorButton --- .../components/autocomplete/__tests__/autocomplete.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 085707a1c4..a8b2637498 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -185,7 +185,7 @@ describe("Autocomplete", () => { const selectorButton = container.querySelector( "[data-slot='inner-wrapper'] button:nth-of-type(2)", - ); + )!; expect(selectorButton).not.toBeNull(); From 6b4fb7bfe8d9c3da88054040ae79dd11a5671895 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Tue, 7 May 2024 19:30:15 +0800 Subject: [PATCH 21/68] fix(autocomplete): revise focus logic --- packages/components/autocomplete/src/use-autocomplete.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 446a629b0d..812bdb7e2b 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -326,7 +326,11 @@ export function useAutocomplete(originalProps: UseAutocomplete }, [isOpen]); useEffect(() => { - onFocus(isOpen); + if (isOpen || !!state.selectedItem) { + inputRef?.current?.focus(); + } else { + inputRef?.current?.blur(); + } }, [isOpen]); // to prevent the error message: @@ -365,8 +369,7 @@ export function useAutocomplete(originalProps: UseAutocomplete const onFocus = useCallback( (isFocused: boolean) => { - if (isFocused) inputRef.current?.focus(); - else inputRef.current?.blur(); + inputRef.current?.focus(); state.setFocused(isFocused); }, [state, inputRef], From 0ccf2aa9b0d3197c7263a6bcc6ccb82f5550ba2a Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 13:11:09 +0800 Subject: [PATCH 22/68] feat(autocomplete): add internal focus state and add shouldCloseOnInteractOutside --- .../autocomplete/src/use-autocomplete.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 812bdb7e2b..c2d155badf 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -6,7 +6,7 @@ import {autocomplete} from "@nextui-org/theme"; import {useFilter} from "@react-aria/i18n"; import {FilterFn, useComboBoxState} from "@react-stately/combobox"; import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; -import {ReactNode, useCallback, useEffect, useMemo, useRef} from "react"; +import {ReactNode, useCallback, useEffect, useMemo, useRef, useState} from "react"; import {ComboBoxProps} from "@react-types/combobox"; import {PopoverProps} from "@nextui-org/popover"; import {ListboxProps} from "@nextui-org/listbox"; @@ -199,6 +199,8 @@ export function useAutocomplete(originalProps: UseAutocomplete const inputRef = useDOMRef(ref); const scrollShadowRef = useDOMRef(scrollRefProp); + const [shouldFocus, setShouldFocus] = useState(false); + const { buttonProps, inputProps, @@ -278,6 +280,11 @@ export function useAutocomplete(originalProps: UseAutocomplete color: isInvalid ? "danger" : originalProps?.color, isIconOnly: true, disableAnimation, + onClick: () => { + if (state.isOpen) { + setShouldFocus(true); + } + }, }, selectorButtonProps, ), @@ -326,10 +333,11 @@ export function useAutocomplete(originalProps: UseAutocomplete }, [isOpen]); useEffect(() => { - if (isOpen || !!state.selectedItem) { + if (shouldFocus || isOpen) { inputRef?.current?.focus(); } else { inputRef?.current?.blur(); + if (shouldFocus) setShouldFocus(false); } }, [isOpen]); @@ -397,13 +405,17 @@ export function useAutocomplete(originalProps: UseAutocomplete onPress: (e: PressEvent) => { slotsProps.clearButtonProps?.onPress?.(e); + const inputFocused = inputRef.current === document.activeElement; + if (state.selectedItem) { onClear(); + setShouldFocus(true); } else { - const inputFocused = inputRef.current === document.activeElement; - allowsCustomValue && state.setInputValue(""); - !inputFocused && onFocus(true); + if (!inputFocused) { + onFocus(true); + setShouldFocus(true); + } } }, "data-visible": !!state.selectedItem || state.inputValue?.length > 0, @@ -452,6 +464,16 @@ export function useAutocomplete(originalProps: UseAutocomplete }), }, disableFocusManagement: true, + shouldCloseOnInteractOutside: (element: any) => { + // Don't close if the click is within the trigger or the popover itself + let trigger = inputWrapperRef?.current; + + if (!trigger || !trigger.contains(element)) { + setShouldFocus(false); + } + + return !trigger || !trigger.contains(element); + }, } as unknown as PopoverProps; }; From cc68482665491f097c2c6913db19ce4d1d93d33a Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 13:25:14 +0800 Subject: [PATCH 23/68] feat(autocomplete): handle selectedItem change --- packages/components/autocomplete/src/use-autocomplete.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index c2d155badf..ebe2a5d3fb 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -332,6 +332,12 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen]); + useEffect(() => { + if (!!state.selectedItem) { + setShouldFocus(true); + } + }, [state.selectedItem]); + useEffect(() => { if (shouldFocus || isOpen) { inputRef?.current?.focus(); From 5142372b4f5523ef3cbeef8236a68e4ecc61acb0 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 16:00:57 +0800 Subject: [PATCH 24/68] feat(autocomplete): add clear button test --- .../__tests__/autocomplete.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index a8b2637498..4dd9fdc4f1 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -166,6 +166,58 @@ describe("Autocomplete", () => { 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 dropdown + await act(async () => { + await userEvent.click(autocomplete); + }); + + // assert that the autocomplete dropdown 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 dropdown by clicking selector button", async () => { const wrapper = render( From 8acae36937175c64b8eacb824cf353c13c761c85 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 16:12:20 +0800 Subject: [PATCH 25/68] feat(changeset): add changeset --- .changeset/good-crabs-clap.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/good-crabs-clap.md diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md new file mode 100644 index 0000000000..77943078f9 --- /dev/null +++ b/.changeset/good-crabs-clap.md @@ -0,0 +1,8 @@ +--- +"@nextui-org/autocomplete": patch +"@nextui-org/modal": patch +"@nextui-org/popover": patch +"@nextui-org/select": patch +--- + +Revise focus behaviours (#2849, #2834, #2779, #2962) From ebd1621d8054392240f411d79db8fe2e93ef2d5e Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 16:41:42 +0800 Subject: [PATCH 26/68] refactor(components): use the original order --- packages/components/popover/src/use-aria-popover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index 0608abf758..cf25cf4368 100644 --- a/packages/components/popover/src/use-aria-popover.ts +++ b/packages/components/popover/src/use-aria-popover.ts @@ -53,8 +53,8 @@ export function useReactAriaPopover( scrollRef, shouldFlip, boundaryElement, - shouldCloseOnBlur = true, isDismissable = true, + shouldCloseOnBlur = true, placement: placementProp = "top", containerPadding, shouldCloseOnInteractOutside, From be0935bc902bbff10edde1948b4c93fc1487d65c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 16:42:05 +0800 Subject: [PATCH 27/68] refactor(autocomplete): add more comments --- .../autocomplete/src/use-autocomplete.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index ebe2a5d3fb..7836da5a93 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -281,6 +281,8 @@ export function useAutocomplete(originalProps: UseAutocomplete isIconOnly: true, disableAnimation, onClick: () => { + // if the listbox is open, clicking selector button should + // close the listbox and focus on the input if (state.isOpen) { setShouldFocus(true); } @@ -332,20 +334,18 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen]); + // react aria has different focus strategies internally + // hence, handle focus behaviours on our side for better flexibilty useEffect(() => { if (!!state.selectedItem) { setShouldFocus(true); - } - }, [state.selectedItem]); - - useEffect(() => { - if (shouldFocus || isOpen) { + } else if (shouldFocus || isOpen) { inputRef?.current?.focus(); } else { inputRef?.current?.blur(); if (shouldFocus) setShouldFocus(false); } - }, [isOpen]); + }, [shouldFocus, isOpen, state.selectedItem]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. @@ -469,12 +469,16 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, + // react aria would update the focus back to trigger + // while we have different behaviour in autocomplete + // hence, disable focus management and manage on our side disableFocusManagement: true, shouldCloseOnInteractOutside: (element: any) => { - // Don't close if the click is within the trigger or the popover itself let trigger = inputWrapperRef?.current; if (!trigger || !trigger.contains(element)) { + // adding blur logic in shouldCloseOnInteractOutside + // to cater the case like autocomplete inside modal setShouldFocus(false); } From 6f32481b053e9790a5c277d8592dc2d68d4892d2 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 21:25:42 +0800 Subject: [PATCH 28/68] fix(autocomplete): revise focus behaviours --- .../autocomplete/src/use-autocomplete.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 7836da5a93..2a26dd0aa9 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -336,16 +336,15 @@ export function useAutocomplete(originalProps: UseAutocomplete // react aria has different focus strategies internally // hence, handle focus behaviours on our side for better flexibilty + useEffect(() => { - if (!!state.selectedItem) { - setShouldFocus(true); - } else if (shouldFocus || isOpen) { + if (shouldFocus || isOpen) { inputRef?.current?.focus(); } else { inputRef?.current?.blur(); if (shouldFocus) setShouldFocus(false); } - }, [shouldFocus, isOpen, state.selectedItem]); + }, [shouldFocus, isOpen]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. @@ -408,21 +407,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); - const inputFocused = inputRef.current === document.activeElement; - if (state.selectedItem) { onClear(); - setShouldFocus(true); } else { - allowsCustomValue && state.setInputValue(""); - if (!inputFocused) { - onFocus(true); - setShouldFocus(true); + if (allowsCustomValue) { + state.setInputValue(""); + state.close(); } } + inputRef?.current?.focus(); }, "data-visible": !!state.selectedItem || state.inputValue?.length > 0, className: slots.clearButton({ @@ -479,7 +477,14 @@ export function useAutocomplete(originalProps: UseAutocomplete if (!trigger || !trigger.contains(element)) { // adding blur logic in shouldCloseOnInteractOutside // to cater the case like autocomplete inside modal - setShouldFocus(false); + if (shouldFocus) { + setShouldFocus(false); + } + } else { + // keep it focus + if (!shouldFocus) { + setShouldFocus(true); + } } return !trigger || !trigger.contains(element); From 55ee11ebee8d61797c27ad74b22fe9928ed58d2c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 8 May 2024 21:28:05 +0800 Subject: [PATCH 29/68] refactor(autocomplete): rename to listbox --- .../__tests__/autocomplete.test.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 4dd9fdc4f1..60e6a32e02 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -154,12 +154,12 @@ describe("Autocomplete", () => { const autocomplete = wrapper.getByTestId("autocomplete"); - // 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"); // assert that input is focused @@ -183,12 +183,12 @@ describe("Autocomplete", () => { const autocomplete = wrapper.getByTestId("autocomplete"); - // 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"); let options = wrapper.getAllByRole("option"); @@ -218,7 +218,7 @@ describe("Autocomplete", () => { expect(autocomplete).toHaveFocus(); }); - it("should open and close dropdown by clicking selector button", async () => { + it("should open and close listbox by clicking selector button", async () => { const wrapper = render( @@ -243,30 +243,30 @@ describe("Autocomplete", () => { const autocomplete = wrapper.getByTestId("autocomplete"); - // open the select dropdown by clicking selector button + // open the select listbox by clicking selector button await act(async () => { await userEvent.click(selectorButton); }); - // assert that the autocomplete dropdown is open + // assert that the autocomplete listbox is open expect(autocomplete).toHaveAttribute("aria-expanded", "true"); // assert that input is focused expect(autocomplete).toHaveFocus(); - // close the select dropdown by clicking selector button again + // close the select listbox by clicking selector button again await act(async () => { await userEvent.click(selectorButton); }); - // assert that the autocomplete dropdown is closed + // 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 dropdown when clicking outside autocomplete", async () => { + 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 @@ -307,7 +307,7 @@ describe("Autocomplete", () => { 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( @@ -336,12 +336,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 @@ -349,7 +349,7 @@ 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 @@ -373,12 +373,12 @@ describe("Autocomplete", () => { const autocomplete = wrapper.getByTestId("autocomplete"); - // open the select dropdown + // open the 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"); // assert that input is focused From d81eb44e54e67f713b35a228d7580f083b4be87c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 12 May 2024 14:37:51 +0800 Subject: [PATCH 30/68] chore(popover): remove disableFocusManagement from popover --- packages/components/popover/src/free-solo-popover.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index 7f7bd9a93a..991526e8f5 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -23,7 +23,6 @@ export interface FreeSoloPopoverProps extends Omit originX?: number; originY?: number; }; - disableFocusManagement?: boolean; } type FreeSoloPopoverWrapperProps = { @@ -87,7 +86,7 @@ const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( FreeSoloPopoverWrapper.displayName = "NextUI.FreeSoloPopoverWrapper"; const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( - ({children, transformOrigin, disableFocusManagement = false, ...props}, ref) => { + ({children, transformOrigin = false, ...props}, ref) => { const { Component, state, @@ -130,7 +129,7 @@ const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( }, [backdrop, disableAnimation, getBackdropProps]); return ( - + {!isNonModal && backdropContent} Date: Sun, 12 May 2024 14:38:09 +0800 Subject: [PATCH 31/68] chore(autocomplete): remove disableFocusManagement from autocomplete --- packages/components/autocomplete/src/use-autocomplete.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 2a26dd0aa9..56608ffa26 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -467,10 +467,6 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, - // react aria would update the focus back to trigger - // while we have different behaviour in autocomplete - // hence, disable focus management and manage on our side - disableFocusManagement: true, shouldCloseOnInteractOutside: (element: any) => { let trigger = inputWrapperRef?.current; From 3bc9b6cb3a1791a3dda876d938097cd6d152a14a Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 12 May 2024 14:39:39 +0800 Subject: [PATCH 32/68] chore(changeset): add issue number --- .changeset/good-crabs-clap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index 77943078f9..e88e9401bd 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -5,4 +5,4 @@ "@nextui-org/select": patch --- -Revise focus behaviours (#2849, #2834, #2779, #2962) +Revise focus behaviours (#2849, #2834, #2779, #2962, #2872) From 99d4e1e765171387f4e710eb4044beca13aaef0b Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 12 May 2024 14:52:46 +0800 Subject: [PATCH 33/68] fix(popover): don't set default value to transformOrigin --- packages/components/popover/src/free-solo-popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index 991526e8f5..edab8f19ec 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -86,7 +86,7 @@ const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( FreeSoloPopoverWrapper.displayName = "NextUI.FreeSoloPopoverWrapper"; const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( - ({children, transformOrigin = false, ...props}, ref) => { + ({children, transformOrigin, ...props}, ref) => { const { Component, state, From d40e792fbf9f40672eca026e04f27bfb9e8e18fa Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 13 May 2024 15:20:26 +0800 Subject: [PATCH 34/68] fix(autocomplete): revise shouldCloseOnInteractOutside logic --- packages/components/autocomplete/src/use-autocomplete.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 56608ffa26..2b7592505e 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -470,12 +470,19 @@ export function useAutocomplete(originalProps: UseAutocomplete shouldCloseOnInteractOutside: (element: any) => { let trigger = inputWrapperRef?.current; + const isOverlay = element?.children?.[0]?.getAttribute("role") === "dialog"; + if (!trigger || !trigger.contains(element)) { // adding blur logic in shouldCloseOnInteractOutside // to cater the case like autocomplete inside modal if (shouldFocus) { setShouldFocus(false); } + // if it is not clicking overlay, close the listbox + // e.g. clicking another autocomplete + if (!isOverlay) { + state.close(); + } } else { // keep it focus if (!shouldFocus) { @@ -483,7 +490,7 @@ export function useAutocomplete(originalProps: UseAutocomplete } } - return !trigger || !trigger.contains(element); + return isOverlay; }, } as unknown as PopoverProps; }; From 47e3503b9f9b79247f86b881a917dcef12efa0aa Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 13 May 2024 17:49:16 +0800 Subject: [PATCH 35/68] feat(autocomplete): should close listbox by clicking another autocomplete --- .../__tests__/autocomplete.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 60e6a32e02..32f1ef1519 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -499,3 +499,80 @@ describe("Autocomplete with React Hook Form", () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +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(); +}); From a3ecd117e1978f5964bccb6f6e3723fcd8692f6e Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 13 May 2024 17:57:22 +0800 Subject: [PATCH 36/68] fix(popover): add disableFocusManagement to overlay --- packages/components/popover/src/popover.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 ( From 9a7b8c9edaa189062ef966b75b14416bdf170e77 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 13 May 2024 18:23:40 +0800 Subject: [PATCH 37/68] refactor(autocomplete): revise comments and refactor shouldCloseOnInteractOutside --- .../autocomplete/src/use-autocomplete.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 2b7592505e..69893b48ea 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -470,27 +470,27 @@ export function useAutocomplete(originalProps: UseAutocomplete shouldCloseOnInteractOutside: (element: any) => { let trigger = inputWrapperRef?.current; - const isOverlay = element?.children?.[0]?.getAttribute("role") === "dialog"; + // check if the current autocomplete is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + // if interacting outside the autocomplete if (!trigger || !trigger.contains(element)) { - // adding blur logic in shouldCloseOnInteractOutside - // to cater the case like autocomplete inside modal - if (shouldFocus) { - setShouldFocus(false); - } - // if it is not clicking overlay, close the listbox + // blur the input in case it is currently focused + setShouldFocus(false); + // close the listbox close the listboxif it is not clicking overlay // e.g. clicking another autocomplete - if (!isOverlay) { + if (!isWithinModal) { state.close(); } } else { - // keep it focus - if (!shouldFocus) { - setShouldFocus(true); - } + // otherwise the autocomplete input should keep focused + setShouldFocus(true); } - return isOverlay; + // if the autocomplete is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; }, } as unknown as PopoverProps; }; From 92f4fb1ab53fb57c6b5e735521779084c0b41fe3 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 13 May 2024 18:24:23 +0800 Subject: [PATCH 38/68] feat(changeset): add issue number --- .changeset/good-crabs-clap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index e88e9401bd..242817286c 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -5,4 +5,4 @@ "@nextui-org/select": patch --- -Revise focus behaviours (#2849, #2834, #2779, #2962, #2872) +Revise focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974) From 305d784372c3cb755f777eb5546daf5fc6e6862c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 21:44:06 +0800 Subject: [PATCH 39/68] fix(autocomplete): merge with selectorButtonProps.onClick --- packages/components/autocomplete/src/use-autocomplete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index a34360c495..573e6c664c 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -284,13 +284,13 @@ export function useAutocomplete(originalProps: UseAutocomplete color: isInvalid ? "danger" : originalProps?.color, isIconOnly: true, disableAnimation, - onClick: () => { + onClick: chain(() => { // if the listbox is open, clicking selector button should // close the listbox and focus on the input if (state.isOpen) { setShouldFocus(true); } - }, + }, selectorButtonProps.onClick), }, selectorButtonProps, ), From 872d5712045e3c63211a7525a6b91c9f71879847 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 21:51:10 +0800 Subject: [PATCH 40/68] refactor(autocomplete): remove extra line --- packages/components/autocomplete/src/use-autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 573e6c664c..b611d8900f 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -340,7 +340,6 @@ export function useAutocomplete(originalProps: UseAutocomplete // react aria has different focus strategies internally // hence, handle focus behaviours on our side for better flexibilty - useEffect(() => { if (shouldFocus || isOpen) { inputRef?.current?.focus(); From f209ba346ac15286eedb09b3679703f7af86868b Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 23:05:30 +0800 Subject: [PATCH 41/68] refactor(autocomplete): revise comment --- packages/components/autocomplete/src/use-autocomplete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index b611d8900f..18fd944744 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -480,8 +480,8 @@ export function useAutocomplete(originalProps: UseAutocomplete if (!trigger || !trigger.contains(element)) { // blur the input in case it is currently focused setShouldFocus(false); - // close the listbox close the listboxif it is not clicking overlay - // e.g. clicking another autocomplete + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another autocomplete should close the current one and open the another one if (!isWithinModal) { state.close(); } From 3432a55bf59a08d35579be0a4426db6b9107304b Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 23:06:24 +0800 Subject: [PATCH 42/68] feat(select): add shouldCloseOnInteractOutside --- packages/components/select/src/use-select.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index cd837a1250..73b08c7fe3 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -518,6 +518,26 @@ export function useSelect(originalProps: UseSelectProps) { ? // 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: (element: any) => { + let trigger = triggerRef?.current; + + // check if the current select is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the select + if (!trigger || !trigger.contains(element)) { + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another select should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the select is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; + }, } as PopoverProps; }, [ From caa973f0f0fa81e6f58a50feb64af5fb8b8a389d Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 23:07:45 +0800 Subject: [PATCH 43/68] feat(dropdown): add shouldCloseOnInteractOutside --- .../components/dropdown/src/use-dropdown.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/components/dropdown/src/use-dropdown.ts b/packages/components/dropdown/src/use-dropdown.ts index 8a2aa62012..3aba333516 100644 --- a/packages/components/dropdown/src/use-dropdown.ts +++ b/packages/components/dropdown/src/use-dropdown.ts @@ -118,6 +118,26 @@ export function useDropdown(props: UseDropdownProps) { ...props.classNames, content: clsx(classNames, classNamesProp?.content, props.className), }, + shouldCloseOnInteractOutside: (element: any) => { + let trigger = triggerRef?.current; + + // check if the current dropdown is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the dropdown + if (!trigger || !trigger.contains(element)) { + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another dropdown should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the dropdown is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; + }, }); const getMenuTriggerProps: PropGetter = ( From 20354adf7c77d1cd9ec7ac7d8aedfbd8e41943d1 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 23:09:53 +0800 Subject: [PATCH 44/68] feat(date-picker): add shouldCloseOnInteractOutside --- .../date-picker/src/use-date-picker.ts | 20 +++++++++++++++++++ .../date-picker/src/use-date-range-picker.ts | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index fa5b97bfcf..687c240477 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -166,6 +166,26 @@ export function useDatePicker({ ), }), }, + shouldCloseOnInteractOutside: (element: any) => { + let trigger = domRef?.current; + + // check if the current date picker is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the date picker + if (!trigger || !trigger.contains(element)) { + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another date picker should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the date picker is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; + }, }; }; 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 4df77b48f6..b836d74354 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -205,6 +205,26 @@ export function useDateRangePicker({ ), }), }, + shouldCloseOnInteractOutside: (element: any) => { + let trigger = domRef?.current; + + // check if the current date range picker is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the date range picker + if (!trigger || !trigger.contains(element)) { + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another date range picker should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the date range picker is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; + }, } as PopoverProps; }; From 3f3b9ad1859ce675e4d4bdf766afd02a3a3fb439 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Sun, 19 May 2024 23:21:01 +0800 Subject: [PATCH 45/68] feat(changeset): add dropdown and date-picker --- .changeset/good-crabs-clap.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index 242817286c..da8356c242 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -2,7 +2,9 @@ "@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 --- Revise focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974) From ce8007faa7677ecbbfa9f251b2097ba0b06eee15 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 00:13:30 +0800 Subject: [PATCH 46/68] fix(popover): revise shouldCloseOnInteractOutside --- .../components/popover/src/use-aria-popover.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index cf25cf4368..73e88bd49f 100644 --- a/packages/components/popover/src/use-aria-popover.ts +++ b/packages/components/popover/src/use-aria-popover.ts @@ -79,7 +79,23 @@ export function useReactAriaPopover( // Don't close if the click is within the trigger or the popover itself let trigger = triggerRef?.current; - return !trigger || !trigger.contains(element); + // check if the current popover is within the modal + const isWithinModal = + element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the popover + if (!trigger || !trigger.contains(element)) { + // close the listbox close the listbox if it is not clicking overlay + // e.g. clicking another popover should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the popover is in modal, + // clicking the overlay should close the listbox instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; }, }, popoverRef, From d2600495dc2cf8b7c6b368b2baaa76eae46741b8 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:44:12 +0800 Subject: [PATCH 47/68] feat(date-picker): integrate with ariaShouldCloseOnInteractOutside --- packages/components/date-picker/package.json | 1 + .../date-picker/src/use-date-picker.ts | 23 +++---------------- .../date-picker/src/use-date-range-picker.ts | 23 +++---------------- 3 files changed, 7 insertions(+), 40 deletions(-) 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 687c240477..d1d030a002 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -14,6 +14,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"; @@ -166,26 +167,8 @@ export function useDatePicker({ ), }), }, - shouldCloseOnInteractOutside: (element: any) => { - let trigger = domRef?.current; - - // check if the current date picker is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the date picker - if (!trigger || !trigger.contains(element)) { - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another date picker should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } - - // if the date picker is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + 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 b836d74354..7a834fa39c 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -20,6 +20,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 @@ -205,26 +206,8 @@ export function useDateRangePicker({ ), }), }, - shouldCloseOnInteractOutside: (element: any) => { - let trigger = domRef?.current; - - // check if the current date range picker is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the date range picker - if (!trigger || !trigger.contains(element)) { - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another date range picker should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } - - // if the date range picker is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + shouldCloseOnInteractOutside: (element: Element) => + ariaShouldCloseOnInteractOutside(element, domRef, state), } as PopoverProps; }; From d81cabecaf712d7611c7b740295913443c64dac5 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:44:30 +0800 Subject: [PATCH 48/68] feat(select): integrate with ariaShouldCloseOnInteractOutside --- packages/components/select/src/use-select.ts | 23 +++----------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 73b08c7fe3..316308f51d 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 = { @@ -518,26 +519,8 @@ export function useSelect(originalProps: UseSelectProps) { ? // 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: (element: any) => { - let trigger = triggerRef?.current; - - // check if the current select is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the select - if (!trigger || !trigger.contains(element)) { - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another select should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } - - // if the select is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + shouldCloseOnInteractOutside: (element: Element) => + ariaShouldCloseOnInteractOutside(element, triggerRef, state), } as PopoverProps; }, [ From 5716624e5e2e585086040b4ca09779b10904682c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:45:33 +0800 Subject: [PATCH 49/68] feat(dropdown): integrate with ariaShouldCloseOnInteractOutside --- packages/components/dropdown/package.json | 1 + .../components/dropdown/src/use-dropdown.ts | 23 +++---------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/components/dropdown/package.json b/packages/components/dropdown/package.json index d239d69019..bcc879474a 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 3aba333516..076eaf5b6d 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"; @@ -118,26 +119,8 @@ export function useDropdown(props: UseDropdownProps) { ...props.classNames, content: clsx(classNames, classNamesProp?.content, props.className), }, - shouldCloseOnInteractOutside: (element: any) => { - let trigger = triggerRef?.current; - - // check if the current dropdown is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the dropdown - if (!trigger || !trigger.contains(element)) { - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another dropdown should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } - - // if the dropdown is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + shouldCloseOnInteractOutside: (element: Element) => + ariaShouldCloseOnInteractOutside(element, triggerRef, state), }); const getMenuTriggerProps: PropGetter = ( From cbfb6bf0bdd19bbdb2dc83d21cf580a0c2c6605d Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:46:57 +0800 Subject: [PATCH 50/68] feat(popover): integrate with ariaShouldCloseOnInteractOutside --- .../popover/src/use-aria-popover.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/components/popover/src/use-aria-popover.ts b/packages/components/popover/src/use-aria-popover.ts index 73e88bd49f..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 { /** @@ -75,28 +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; - - // check if the current popover is within the modal - const isWithinModal = - element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the popover - if (!trigger || !trigger.contains(element)) { - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another popover should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } - - // if the popover is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + : (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state), }, popoverRef, ); From 1e25f4fa8c4dd7f23969fa19fd7b5a0f0b125be1 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:53:05 +0800 Subject: [PATCH 51/68] feat(aria-utils): ariaShouldCloseOnInteractOutside --- packages/utilities/aria-utils/package.json | 1 + packages/utilities/aria-utils/src/index.ts | 1 + .../ariaShouldCloseOnInteractOutside.ts | 35 +++++++++++++++++++ .../aria-utils/src/overlays/index.ts | 1 + 4 files changed, 38 insertions(+) create mode 100644 packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts 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..63670836ca --- /dev/null +++ b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts @@ -0,0 +1,35 @@ +import {RefObject} from "react"; + +/** + * Used to handle the outside interaction for popover-based components + * e.g. dropdown, datepicker, date-range-picker, popover, select, autocomplete etc + * @param element - element originally from `shouldCloseOnInteractOutside` + * @param triggerRef - The trigger ref + * @param state - The trigger state from the target component + * @returns - a boolean value which is same as shouldCloseOnInteractOutside + */ +export const ariaShouldCloseOnInteractOutside = ( + element: Element, + triggerRef: RefObject, + state: any, +) => { + // Don't close if the click is within the trigger or the popover itself + let trigger = triggerRef?.current; + + // check if the current component is within the modal + const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + + // if interacting outside the component + if (!trigger || !trigger.contains(element)) { + // close the popover close the popover if it is not clicking overlay + // e.g. clicking another component should close the current one and open the another one + if (!isWithinModal) { + state.close(); + } + } + + // if the component is in modal, + // clicking the overlay should close the popover instead of closing the modal + // otherwise, allow interaction with other elements + return isWithinModal; +}; 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"; From 23cc9884494a5e91725cd616332f5a2937cba0ac Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 11:53:18 +0800 Subject: [PATCH 52/68] chore(deps): update pnpm-lock.yaml --- pnpm-lock.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cee156200..ee6a7ff750 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 @@ -3648,6 +3654,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) @@ -5967,10 +5976,6 @@ packages: peerDependencies: '@effect-ts/otel-node': '*' peerDependenciesMeta: - '@effect-ts/core': - optional: true - '@effect-ts/otel': - optional: true '@effect-ts/otel-node': optional: true dependencies: @@ -22464,9 +22469,6 @@ packages: resolution: {integrity: sha512-W+gxAq7aQ9dJIg/XLKGcRT0cvnStFAQHPaI0pvD0U2l6IVLueUAm3nwN7lkY62zZNmlvNx6jNtE4wlbS+CyqSg==} engines: {node: '>= 12.0.0'} hasBin: true - peerDependenciesMeta: - '@parcel/core': - optional: true dependencies: '@parcel/config-default': 2.12.0(@parcel/core@2.12.0)(typescript@4.9.5) '@parcel/core': 2.12.0 From c619566efc765837ea5e2bfd21bdee14aaf9424c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 12:00:41 +0800 Subject: [PATCH 53/68] feat(autocomplete): integrate with ariaShouldCloseOnInteractOutside --- .../autocomplete/src/use-autocomplete.ts | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 18fd944744..87e7f3f1f6 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> { /** @@ -470,31 +471,8 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, - shouldCloseOnInteractOutside: (element: any) => { - let trigger = inputWrapperRef?.current; - - // check if the current autocomplete is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; - - // if interacting outside the autocomplete - if (!trigger || !trigger.contains(element)) { - // blur the input in case it is currently focused - setShouldFocus(false); - // close the listbox close the listbox if it is not clicking overlay - // e.g. clicking another autocomplete should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } - } else { - // otherwise the autocomplete input should keep focused - setShouldFocus(true); - } - - // if the autocomplete is in modal, - // clicking the overlay should close the listbox instead of closing the modal - // otherwise, allow interaction with other elements - return isWithinModal; - }, + shouldCloseOnInteractOutside: (element: Element) => + ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, setShouldFocus), } as unknown as PopoverProps; }; From 9b899bbaf0fe0d3f253b115353616a8904ad9239 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 12:01:21 +0800 Subject: [PATCH 54/68] feat(aria-utils): handle setShouldFocus logic --- .../src/overlays/ariaShouldCloseOnInteractOutside.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts index 63670836ca..4b5ff86eb7 100644 --- a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts +++ b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts @@ -6,12 +6,14 @@ import {RefObject} from "react"; * @param element - element originally from `shouldCloseOnInteractOutside` * @param triggerRef - The trigger ref * @param state - The trigger state from the target component + * @param setShouldFocus - Set 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, triggerRef: RefObject, state: any, + setShouldFocus?: (shouldFocus: boolean) => void, ) => { // Don't close if the click is within the trigger or the popover itself let trigger = triggerRef?.current; @@ -21,11 +23,20 @@ export const ariaShouldCloseOnInteractOutside = ( // if interacting outside the component if (!trigger || !trigger.contains(element)) { + if (setShouldFocus) { + // blur the component (e.g. autocomplete) + setShouldFocus(false); + } // close the popover close the popover if it is not clicking overlay // e.g. clicking another component should close the current one and open the another one if (!isWithinModal) { state.close(); } + } else { + if (setShouldFocus) { + // otherwise the component (e.g. autocomplete) should keep focused + setShouldFocus(true); + } } // if the component is in modal, From d215e111071eafd5c967b65c9f379c3e4e7b7382 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 12:02:21 +0800 Subject: [PATCH 55/68] feat(changeset): add @nextui-org/aria-utils --- .changeset/good-crabs-clap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index da8356c242..8c36ba06f9 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -5,6 +5,7 @@ "@nextui-org/dropdown": patch "@nextui-org/select": patch "@nextui-org/date-picker": patch +"@nextui-org/aria-utils": patch --- Revise focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974) From e30795f4277ea690e92c19a6760a2c370c32b4ed Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 12:25:09 +0800 Subject: [PATCH 56/68] chore(autocomplete): put the test into correct group --- .../__tests__/autocomplete.test.tsx | 158 +++++++++--------- 1 file changed, 81 insertions(+), 77 deletions(-) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 32f1ef1519..003c587f29 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -396,6 +396,87 @@ describe("Autocomplete", () => { // 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("Autocomplete with React Hook Form", () => { @@ -499,80 +580,3 @@ describe("Autocomplete with React Hook Form", () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); - -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(); -}); From 1a854c17bd1974bd7dc1ca8c658b82121b80686e Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 12:47:45 +0800 Subject: [PATCH 57/68] feat(select): should close listbox by clicking another select --- .../select/__tests__/select.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 3c46a4f59f..a526bd52b0 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -397,6 +397,65 @@ describe("Select", () => { expect(onSelectionChange).toBeCalledTimes(0); }); }); + + 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", () => { From e3ac5580d14612e99d4b6f0cbf5a95b10bc74948 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 13:16:04 +0800 Subject: [PATCH 58/68] feat(dropdown): should close listbox by clicking another dropdown --- .../dropdown/__tests__/dropdown.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/components/dropdown/__tests__/dropdown.test.tsx b/packages/components/dropdown/__tests__/dropdown.test.tsx index 3080321f5b..ff23202753 100644 --- a/packages/components/dropdown/__tests__/dropdown.test.tsx +++ b/packages/components/dropdown/__tests__/dropdown.test.tsx @@ -537,4 +537,67 @@ 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"); + }); }); From 8cdd8f394a87dbae7e858849883ecfffee0f4dd9 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 13:18:05 +0800 Subject: [PATCH 59/68] feat(popover): should close listbox by clicking another popover --- .../popover/__tests__/popover.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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"); + }); }); From 0a52e6c44cd9ed0b837eb2f99bb01bb1082fd15b Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 13:29:48 +0800 Subject: [PATCH 60/68] feat(date-picker): should close listbox by clicking another datepicker --- .../__tests__/date-picker.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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(); + }); }); From 4d760444b4b3b00730fa894803a1a91ca5b48138 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Mon, 20 May 2024 21:21:12 +0800 Subject: [PATCH 61/68] chore(changeset): add issue numbers and revise changeset message --- .changeset/good-crabs-clap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index 8c36ba06f9..da12683136 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -8,4 +8,4 @@ "@nextui-org/aria-utils": patch --- -Revise focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974) +Revise popover-based focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974, #1920, #1287) From c573f2381b3890e882dea97f724c806dafacb10d Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 11:50:49 +0800 Subject: [PATCH 62/68] refactor(autocomplete): change to useRef instead --- .../autocomplete/src/use-autocomplete.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index ea3953c993..3b7362d077 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -7,7 +7,7 @@ import {autocomplete} from "@nextui-org/theme"; import {useFilter} from "@react-aria/i18n"; import {FilterFn, useComboBoxState} from "@react-stately/combobox"; import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; -import {ReactNode, useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {ReactNode, useCallback, useEffect, useMemo, useRef} from "react"; import {ComboBoxProps} from "@react-types/combobox"; import {PopoverProps} from "@nextui-org/popover"; import {ListboxProps} from "@nextui-org/listbox"; @@ -202,7 +202,8 @@ export function useAutocomplete(originalProps: UseAutocomplete const inputRef = useDOMRef(ref); const scrollShadowRef = useDOMRef(scrollRefProp); - const [shouldFocus, setShouldFocus] = useState(false); + // control the input focus behaviours internally + const shouldFocus = useRef(false); const { buttonProps, @@ -287,7 +288,7 @@ export function useAutocomplete(originalProps: UseAutocomplete // if the listbox is open, clicking selector button should // close the listbox and focus on the input if (state.isOpen) { - setShouldFocus(true); + shouldFocus.current = true; } }, selectorButtonProps.onClick), }, @@ -340,13 +341,13 @@ export function useAutocomplete(originalProps: UseAutocomplete // react aria has different focus strategies internally // hence, handle focus behaviours on our side for better flexibilty useEffect(() => { - if (shouldFocus || isOpen) { + if (shouldFocus.current || isOpen) { inputRef?.current?.focus(); } else { inputRef?.current?.blur(); - if (shouldFocus) setShouldFocus(false); + if (shouldFocus.current) shouldFocus.current = false; } - }, [shouldFocus, isOpen]); + }, [shouldFocus.current, isOpen]); // to prevent the error message: // stopPropagation is now the default behavior for events in React Spectrum. @@ -471,7 +472,7 @@ export function useAutocomplete(originalProps: UseAutocomplete }), }, shouldCloseOnInteractOutside: (element: Element) => - ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, setShouldFocus), + ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, shouldFocus), } as unknown as PopoverProps; }; From 361d6f00a80e54ef84a46b30fd52b6dba6ba535f Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 11:51:12 +0800 Subject: [PATCH 63/68] refactor(autocomplete): change to useRef instead --- .../overlays/ariaShouldCloseOnInteractOutside.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts index 4b5ff86eb7..9c10dfef80 100644 --- a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts +++ b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts @@ -1,4 +1,4 @@ -import {RefObject} from "react"; +import {MutableRefObject, RefObject} from "react"; /** * Used to handle the outside interaction for popover-based components @@ -6,14 +6,15 @@ import {RefObject} from "react"; * @param element - element originally from `shouldCloseOnInteractOutside` * @param triggerRef - The trigger ref * @param state - The trigger state from the target component - * @param setShouldFocus - Set the focus state (used in input-based component such as autocomplete) + * @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, triggerRef: RefObject, state: any, - setShouldFocus?: (shouldFocus: boolean) => void, + shouldFocus?: MutableRefObject, ) => { // Don't close if the click is within the trigger or the popover itself let trigger = triggerRef?.current; @@ -23,9 +24,9 @@ export const ariaShouldCloseOnInteractOutside = ( // if interacting outside the component if (!trigger || !trigger.contains(element)) { - if (setShouldFocus) { + if (shouldFocus !== undefined) { // blur the component (e.g. autocomplete) - setShouldFocus(false); + shouldFocus.current = false; } // close the popover close the popover if it is not clicking overlay // e.g. clicking another component should close the current one and open the another one @@ -33,9 +34,9 @@ export const ariaShouldCloseOnInteractOutside = ( state.close(); } } else { - if (setShouldFocus) { + if (shouldFocus !== undefined) { // otherwise the component (e.g. autocomplete) should keep focused - setShouldFocus(true); + shouldFocus.current = true; } } From 3105163be2c6a6e0a0105bfa57a171fba2bdaa19 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 12:36:24 +0800 Subject: [PATCH 64/68] refactor(aria-utils): revise comments and format code --- .../ariaShouldCloseOnInteractOutside.ts | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts index 9c10dfef80..0be005b516 100644 --- a/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts +++ b/packages/utilities/aria-utils/src/overlays/ariaShouldCloseOnInteractOutside.ts @@ -3,45 +3,39 @@ 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 - element originally from `shouldCloseOnInteractOutside` - * @param triggerRef - The trigger ref - * @param state - The trigger state from the target component + * @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, - triggerRef: RefObject, + ref: RefObject, state: any, shouldFocus?: MutableRefObject, ) => { - // Don't close if the click is within the trigger or the popover itself - let trigger = triggerRef?.current; + let trigger = ref?.current; - // check if the current component is within the modal - const isWithinModal = element?.children?.[0]?.getAttribute("role") === "dialog" ?? false; + // 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)) { - if (shouldFocus !== undefined) { - // blur the component (e.g. autocomplete) - shouldFocus.current = false; - } - // close the popover close the popover if it is not clicking overlay - // e.g. clicking another component should close the current one and open the another one - if (!isWithinModal) { - state.close(); - } + // 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 { - if (shouldFocus !== undefined) { - // otherwise the component (e.g. autocomplete) should keep focused - shouldFocus.current = true; - } + // otherwise the component (e.g. autocomplete) should keep focused + if (shouldFocus) shouldFocus.current = true; } - // if the component is in modal, + // 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 isWithinModal; + return clickOnUnderlay; }; From 01d24760a035646359fd249399b0dbf2b67ab33f Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 19:19:37 +0800 Subject: [PATCH 65/68] chore(changeset): add issue number --- .changeset/good-crabs-clap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/good-crabs-clap.md b/.changeset/good-crabs-clap.md index da12683136..6ab95e745e 100644 --- a/.changeset/good-crabs-clap.md +++ b/.changeset/good-crabs-clap.md @@ -8,4 +8,4 @@ "@nextui-org/aria-utils": patch --- -Revise popover-based focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974, #1920, #1287) +Revise popover-based focus behaviours (#2849, #2834, #2779, #2962, #2872, #2974, #1920, #1287, #3060) From 624488d40da6e203480f48754c5699355db9b888 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 21:12:31 +0800 Subject: [PATCH 66/68] chore: take popoverProps.shouldCloseOnInteractOutside first --- .../autocomplete/src/use-autocomplete.ts | 10 +++-- .../date-picker/src/use-date-picker.ts | 5 ++- .../date-picker/src/use-date-range-picker.ts | 5 ++- .../components/dropdown/src/use-dropdown.ts | 39 +++++++++++-------- packages/components/select/src/use-select.ts | 9 +++-- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 3b7362d077..5728952f56 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -455,13 +455,15 @@ export function useAutocomplete(originalProps: UseAutocomplete } 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( @@ -471,8 +473,10 @@ export function useAutocomplete(originalProps: UseAutocomplete ), }), }, - shouldCloseOnInteractOutside: (element: Element) => - ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, shouldFocus), + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => + ariaShouldCloseOnInteractOutside(element, inputWrapperRef, state, shouldFocus), } as unknown as PopoverProps; }; diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index db3d3b5080..8e2db325c6 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -174,8 +174,9 @@ export function useDatePicker({ ), }), }, - shouldCloseOnInteractOutside: (element: Element) => - ariaShouldCloseOnInteractOutside(element, domRef, state), + 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 70a84074dd..42df170d40 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -216,8 +216,9 @@ export function useDateRangePicker({ ), }), }, - shouldCloseOnInteractOutside: (element: Element) => - ariaShouldCloseOnInteractOutside(element, domRef, state), + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => ariaShouldCloseOnInteractOutside(element, domRef, state), } as PopoverProps; }; diff --git a/packages/components/dropdown/src/use-dropdown.ts b/packages/components/dropdown/src/use-dropdown.ts index 40c73f9387..0124669b95 100644 --- a/packages/components/dropdown/src/use-dropdown.ts +++ b/packages/components/dropdown/src/use-dropdown.ts @@ -105,23 +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), - }, - shouldCloseOnInteractOutside: (element: Element) => - ariaShouldCloseOnInteractOutside(element, triggerRef, state), - }); + 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/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 8a30380ee2..35600017ba 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -501,6 +501,8 @@ export function useSelect(originalProps: UseSelectProps) { const getPopoverProps = useCallback( (props: DOMAttributes = {}) => { + const popoverProps = mergeProps(slotsProps.popoverProps, props); + return { state, triggerRef, @@ -513,14 +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: (element: Element) => - ariaShouldCloseOnInteractOutside(element, triggerRef, state), + shouldCloseOnInteractOutside: popoverProps?.shouldCloseOnInteractOutside + ? popoverProps.shouldCloseOnInteractOutside + : (element: Element) => ariaShouldCloseOnInteractOutside(element, triggerRef, state), } as PopoverProps; }, [ From 3642e715a2418a88a19d5f72ecd27339e3e14c94 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 21:18:07 +0800 Subject: [PATCH 67/68] refactor(autocomplete): remove unnecessary logic --- packages/components/autocomplete/src/use-autocomplete.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 5728952f56..2421e1fbac 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -284,13 +284,6 @@ export function useAutocomplete(originalProps: UseAutocomplete color: isInvalid ? "danger" : originalProps?.color, isIconOnly: true, disableAnimation, - onClick: chain(() => { - // if the listbox is open, clicking selector button should - // close the listbox and focus on the input - if (state.isOpen) { - shouldFocus.current = true; - } - }, selectorButtonProps.onClick), }, selectorButtonProps, ), From c28bf2e5b82e95f23acfaa759104e47a749c5cee Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 23 May 2024 21:20:41 +0800 Subject: [PATCH 68/68] refactor(autocomplete): focus management logic --- .../components/autocomplete/src/use-autocomplete.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 2421e1fbac..83e8a8caea 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -334,12 +334,10 @@ export function useAutocomplete(originalProps: UseAutocomplete // react aria has different focus strategies internally // hence, handle focus behaviours on our side for better flexibilty useEffect(() => { - if (shouldFocus.current || isOpen) { - inputRef?.current?.focus(); - } else { - inputRef?.current?.blur(); - if (shouldFocus.current) shouldFocus.current = false; - } + const action = shouldFocus.current || isOpen ? "focus" : "blur"; + + inputRef?.current?.[action](); + if (action === "blur") shouldFocus.current = false; }, [shouldFocus.current, isOpen]); // to prevent the error message: