Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: popover-based focus behaviour #2854

Merged
merged 73 commits into from May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
103663b
fix(autocomplete): autocomplete focus behaviour
wingkwong Apr 24, 2024
6700c49
feat(autocomplete): add test case for catching blur cases
wingkwong Apr 24, 2024
f8701df
refactor(autocomplete): use isOpen instead
wingkwong Apr 24, 2024
bdb41e0
feat(autocomplete): add "should focus when clicking autocomplete" tes…
wingkwong Apr 24, 2024
a065002
feat(autocomplete): add should set the input after selection
wingkwong Apr 24, 2024
135b27a
Merge branch 'main' into fix/eng-668
wingkwong Apr 25, 2024
3e13a3e
fix(autocomplete): remove shouldUseVirtualFocus
wingkwong Apr 25, 2024
7eb5a2f
fix(autocomplete): uncomment blur logic
wingkwong Apr 25, 2024
e4065b8
Merge branch 'canary' into fix/eng-668
wingkwong May 6, 2024
706186a
refactor(autocomplete): remove state as it is in getPopoverProps
wingkwong May 6, 2024
3f06b1d
refactor(autocomplete): remove unnecessary blur
wingkwong May 6, 2024
f7cba0c
refactor(select): remove unncessary props
wingkwong May 6, 2024
90e6897
fix(popover): use domRef instead
wingkwong May 6, 2024
697a797
fix(popover): revise isNonModal and isDismissable
wingkwong May 6, 2024
15873da
fix(popover): use dialogRef back
wingkwong May 6, 2024
af3876d
fix(popover): rollback
wingkwong May 6, 2024
16e1271
fix(autocomplete): onFocus logic
wingkwong May 7, 2024
8146b4d
feat(popover): set disableFocusManagement to overlay
wingkwong May 7, 2024
b6e6105
feat(modal): set disableFocusManagement to overlay
wingkwong May 7, 2024
4b1a3e4
fix(autocomplete): set disableFocusManagement for autocomplete
wingkwong May 7, 2024
86133a3
feat(popover): include disableFocusManagement prop
wingkwong May 7, 2024
2bb81a3
refactor(autocomplete): revise type in selectorButton
wingkwong May 7, 2024
6b4fb7b
fix(autocomplete): revise focus logic
wingkwong May 7, 2024
0ccf2aa
feat(autocomplete): add internal focus state and add shouldCloseOnInt…
wingkwong May 8, 2024
cc68482
feat(autocomplete): handle selectedItem change
wingkwong May 8, 2024
5142372
feat(autocomplete): add clear button test
wingkwong May 8, 2024
8acae36
feat(changeset): add changeset
wingkwong May 8, 2024
ebd1621
refactor(components): use the original order
wingkwong May 8, 2024
be0935b
refactor(autocomplete): add more comments
wingkwong May 8, 2024
6f32481
fix(autocomplete): revise focus behaviours
wingkwong May 8, 2024
55ee11e
refactor(autocomplete): rename to listbox
wingkwong May 8, 2024
d81eb44
chore(popover): remove disableFocusManagement from popover
wingkwong May 12, 2024
2272b71
chore(autocomplete): remove disableFocusManagement from autocomplete
wingkwong May 12, 2024
3bc9b6c
chore(changeset): add issue number
wingkwong May 12, 2024
99d4e1e
fix(popover): don't set default value to transformOrigin
wingkwong May 12, 2024
d40e792
fix(autocomplete): revise shouldCloseOnInteractOutside logic
wingkwong May 13, 2024
47e3503
feat(autocomplete): should close listbox by clicking another autocomp…
wingkwong May 13, 2024
a3ecd11
fix(popover): add disableFocusManagement to overlay
wingkwong May 13, 2024
9a7b8c9
refactor(autocomplete): revise comments and refactor shouldCloseOnInt…
wingkwong May 13, 2024
92f4fb1
feat(changeset): add issue number
wingkwong May 13, 2024
9ae32ea
Merge branch 'canary' into fix/eng-668
wingkwong May 19, 2024
305d784
fix(autocomplete): merge with selectorButtonProps.onClick
wingkwong May 19, 2024
872d571
refactor(autocomplete): remove extra line
wingkwong May 19, 2024
f209ba3
refactor(autocomplete): revise comment
wingkwong May 19, 2024
3432a55
feat(select): add shouldCloseOnInteractOutside
wingkwong May 19, 2024
caa973f
feat(dropdown): add shouldCloseOnInteractOutside
wingkwong May 19, 2024
20354ad
feat(date-picker): add shouldCloseOnInteractOutside
wingkwong May 19, 2024
3f3b9ad
feat(changeset): add dropdown and date-picker
wingkwong May 19, 2024
ce8007f
fix(popover): revise shouldCloseOnInteractOutside
wingkwong May 19, 2024
d260049
feat(date-picker): integrate with ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
d81cabe
feat(select): integrate with ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
5716624
feat(dropdown): integrate with ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
cbfb6bf
feat(popover): integrate with ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
1e25f4f
feat(aria-utils): ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
23cc988
chore(deps): update pnpm-lock.yaml
wingkwong May 20, 2024
c619566
feat(autocomplete): integrate with ariaShouldCloseOnInteractOutside
wingkwong May 20, 2024
9b899bb
feat(aria-utils): handle setShouldFocus logic
wingkwong May 20, 2024
d215e11
feat(changeset): add @nextui-org/aria-utils
wingkwong May 20, 2024
e30795f
chore(autocomplete): put the test into correct group
wingkwong May 20, 2024
1a854c1
feat(select): should close listbox by clicking another select
wingkwong May 20, 2024
e3ac558
feat(dropdown): should close listbox by clicking another dropdown
wingkwong May 20, 2024
8cdd8f3
feat(popover): should close listbox by clicking another popover
wingkwong May 20, 2024
0a52e6c
feat(date-picker): should close listbox by clicking another datepicker
wingkwong May 20, 2024
e256971
Merge branch 'canary' into fix/eng-668
wingkwong May 20, 2024
4d76044
chore(changeset): add issue numbers and revise changeset message
wingkwong May 20, 2024
cdef3af
Merge branch 'canary' into fix/eng-668
wingkwong May 23, 2024
c573f23
refactor(autocomplete): change to useRef instead
wingkwong May 23, 2024
361d6f0
refactor(autocomplete): change to useRef instead
wingkwong May 23, 2024
3105163
refactor(aria-utils): revise comments and format code
wingkwong May 23, 2024
01d2476
chore(changeset): add issue number
wingkwong May 23, 2024
624488d
chore: take popoverProps.shouldCloseOnInteractOutside first
wingkwong May 23, 2024
3642e71
refactor(autocomplete): remove unnecessary logic
wingkwong May 23, 2024
c28bf2e
refactor(autocomplete): focus management logic
wingkwong May 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions .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)
190 changes: 183 additions & 7 deletions packages/components/autocomplete/__tests__/autocomplete.test.tsx
Expand Up @@ -137,7 +137,136 @@ describe("Autocomplete", () => {
expect(() => wrapper.unmount()).not.toThrow();
});

it("should close dropdown when clicking outside autocomplete", async () => {
it("should focus when clicking autocomplete", async () => {
const wrapper = render(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);

const autocomplete = wrapper.getByTestId("autocomplete");

// open the select listbox
await act(async () => {
await userEvent.click(autocomplete);
});

// assert that the autocomplete listbox is open
expect(autocomplete).toHaveAttribute("aria-expanded", "true");

// assert that input is focused
expect(autocomplete).toHaveFocus();
});

it("should clear value after clicking clear button", async () => {
const wrapper = render(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);

const autocomplete = wrapper.getByTestId("autocomplete");

// open the select listbox
await act(async () => {
await userEvent.click(autocomplete);
});

// assert that the autocomplete listbox is open
expect(autocomplete).toHaveAttribute("aria-expanded", "true");

let options = wrapper.getAllByRole("option");

// select the target item
await act(async () => {
await userEvent.click(options[0]);
});

const {container} = wrapper;

const clearButton = container.querySelector(
"[data-slot='inner-wrapper'] button:nth-of-type(1)",
)!;

expect(clearButton).not.toBeNull();

// select the target item
await act(async () => {
await userEvent.click(clearButton);
});

// assert that the input has empty value
expect(autocomplete).toHaveValue("");

// assert that input is focused
expect(autocomplete).toHaveFocus();
});

it("should open and close listbox by clicking selector button", async () => {
const wrapper = render(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);

const {container} = wrapper;

const selectorButton = container.querySelector(
"[data-slot='inner-wrapper'] button:nth-of-type(2)",
)!;

expect(selectorButton).not.toBeNull();

const autocomplete = wrapper.getByTestId("autocomplete");

// open the select listbox by clicking selector button
await act(async () => {
await userEvent.click(selectorButton);
});

// assert that the autocomplete listbox is open
expect(autocomplete).toHaveAttribute("aria-expanded", "true");

// assert that input is focused
expect(autocomplete).toHaveFocus();

// close the select listbox by clicking selector button again
await act(async () => {
await userEvent.click(selectorButton);
});

// assert that the autocomplete listbox is closed
expect(autocomplete).toHaveAttribute("aria-expanded", "false");

// assert that input is still focused
expect(autocomplete).toHaveFocus();
});

it("should close listbox when clicking outside autocomplete", async () => {
const wrapper = render(
<Autocomplete
aria-label="Favorite Animal"
Expand All @@ -158,12 +287,12 @@ describe("Autocomplete", () => {

const autocomplete = wrapper.getByTestId("close-when-clicking-outside-test");

// open the select dropdown
// open the select listbox
await act(async () => {
await userEvent.click(autocomplete);
});

// assert that the autocomplete dropdown is open
// assert that the autocomplete listbox is open
expect(autocomplete).toHaveAttribute("aria-expanded", "true");

// click outside the autocomplete component
Expand All @@ -173,9 +302,12 @@ describe("Autocomplete", () => {

// assert that the autocomplete is closed
expect(autocomplete).toHaveAttribute("aria-expanded", "false");

// assert that input is not focused
expect(autocomplete).not.toHaveFocus();
});

it("should close dropdown when clicking outside autocomplete with modal open", async () => {
it("should close listbox when clicking outside autocomplete with modal open", async () => {
const wrapper = render(
<Modal isOpen>
<ModalContent>
Expand Down Expand Up @@ -204,21 +336,65 @@ 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
await act(async () => {
await userEvent.click(document.body);
});

// assert that the autocomplete dropdown is closed
// assert that the autocomplete listbox is closed
expect(autocomplete).toHaveAttribute("aria-expanded", "false");

// assert that input is not focused
expect(autocomplete).not.toHaveFocus();
});

it("should set the input after selection", async () => {
const wrapper = render(
<Autocomplete aria-label="Favorite Animal" data-testid="autocomplete" label="Favorite Animal">
<AutocompleteItem key="penguin" value="penguin">
Penguin
</AutocompleteItem>
<AutocompleteItem key="zebra" value="zebra">
Zebra
</AutocompleteItem>
<AutocompleteItem key="shark" value="shark">
Shark
</AutocompleteItem>
</Autocomplete>,
);

const autocomplete = wrapper.getByTestId("autocomplete");

// open the listbox
await act(async () => {
await userEvent.click(autocomplete);
});

// assert that the autocomplete listbox is open
expect(autocomplete).toHaveAttribute("aria-expanded", "true");

// assert that input is focused
expect(autocomplete).toHaveFocus();

let options = wrapper.getAllByRole("option");

expect(options.length).toBe(3);

// select the target item
await act(async () => {
await userEvent.click(options[0]);
});

// assert that the input has target selection
expect(autocomplete).toHaveValue("Penguin");
});
});

Expand Down
3 changes: 1 addition & 2 deletions packages/components/autocomplete/src/autocomplete.tsx
Expand Up @@ -15,7 +15,6 @@ interface Props<T> extends UseAutocompleteProps<T> {}
function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLInputElement>) {
const {
Component,
state,
isOpen,
disableAnimation,
selectorIcon = <ChevronDownIcon />,
Expand All @@ -33,7 +32,7 @@ function Autocomplete<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLI
} = useAutocomplete<T>({...props, ref});

const popoverContent = isOpen ? (
<FreeSoloPopover {...getPopoverProps()} state={state}>
<FreeSoloPopover {...getPopoverProps()}>
<ScrollShadow {...getListBoxWrapperProps()}>
<Listbox {...getListBoxProps()} />
</ScrollShadow>
Expand Down
55 changes: 45 additions & 10 deletions packages/components/autocomplete/src/use-autocomplete.ts
Expand Up @@ -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";
Expand Down Expand Up @@ -199,6 +199,8 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
const inputRef = useDOMRef<HTMLInputElement>(ref);
const scrollShadowRef = useDOMRef<HTMLElement>(scrollRefProp);

const [shouldFocus, setShouldFocus] = useState(false);
wingkwong marked this conversation as resolved.
Show resolved Hide resolved

const {
buttonProps,
inputProps,
Expand Down Expand Up @@ -278,6 +280,13 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
color: isInvalid ? "danger" : originalProps?.color,
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);
}
},
},
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
selectorButtonProps,
),
Expand Down Expand Up @@ -325,12 +334,17 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
}
}, [isOpen]);

// unfocus the input when the popover closes & there's no selected item & no allows custom value
// react aria has different focus strategies internally
// hence, handle focus behaviours on our side for better flexibilty

useEffect(() => {
if (!isOpen && !state.selectedItem && inputRef.current && !allowsCustomValue) {
inputRef.current.blur();
if (shouldFocus || isOpen) {
inputRef?.current?.focus();
} else {
inputRef?.current?.blur();
if (shouldFocus) setShouldFocus(false);
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
}
}, [isOpen, allowsCustomValue]);
}, [shouldFocus, isOpen]);

// to prevent the error message:
// stopPropagation is now the default behavior for events in React Spectrum.
Expand Down Expand Up @@ -363,6 +377,7 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
const onClear = useCallback(() => {
state.setInputValue("");
state.setSelectedKey(null);
state.close();
}, [state]);

const onFocus = useCallback(
Expand Down Expand Up @@ -392,17 +407,20 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
const getClearButtonProps = () =>
({
...mergeProps(buttonProps, slotsProps.clearButtonProps),
// disable original focus and state toggle from react aria
onPressStart: () => {},
onPress: (e: PressEvent) => {
slotsProps.clearButtonProps?.onPress?.(e);

if (state.selectedItem) {
onClear();
} else {
const inputFocused = inputRef.current === document.activeElement;

allowsCustomValue && state.setInputValue("");
!inputFocused && onFocus(true);
if (allowsCustomValue) {
state.setInputValue("");
state.close();
}
}
inputRef?.current?.focus();
},
"data-visible": !!state.selectedItem || state.inputValue?.length > 0,
className: slots.clearButton({
Expand All @@ -429,7 +447,6 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
ref: listBoxRef,
...mergeProps(slotsProps.listboxProps, listBoxProps, {
shouldHighlightOnFocus: true,
shouldUseVirtualFocus: false,
}),
} as ListboxProps);

Expand All @@ -450,6 +467,24 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
),
}),
},
shouldCloseOnInteractOutside: (element: any) => {
let trigger = inputWrapperRef?.current;

if (!trigger || !trigger.contains(element)) {
// adding blur logic in shouldCloseOnInteractOutside
// to cater the case like autocomplete inside modal
if (shouldFocus) {
setShouldFocus(false);
}
} else {
// keep it focus
if (!shouldFocus) {
setShouldFocus(true);
}
}

return !trigger || !trigger.contains(element);
},
} as unknown as PopoverProps;
};

Expand Down
6 changes: 5 additions & 1 deletion packages/components/modal/src/modal.tsx
Expand Up @@ -17,7 +17,11 @@ const Modal = forwardRef<"div", ModalProps>((props, ref) => {
const {children, ...otherProps} = props;
const context = useModal({...otherProps, ref});

const overlay = <Overlay portalContainer={context.portalContainer}>{children}</Overlay>;
const overlay = (
<Overlay disableFocusManagement portalContainer={context.portalContainer}>
{children}
</Overlay>
);

return (
<ModalProvider value={context}>
Expand Down