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(select): 🐛 focus the popover on loop from both ends #159

Merged
merged 2 commits into from
Nov 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 77 additions & 24 deletions src/select/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,21 @@ export const useSelect = createHook<SelectOptions, SelectHTMLProps>({
return { menuRole, hideOnEsc, ...options };
},

useProps(options, { onKeyDown: htmlOnKeyDown, ...htmlProps }) {
useProps(
options,
{
onClick: htmlOnClick,
onKeyDown: htmlOnKeyDown,
onMouseDown: htmlOnMouseDown,
...htmlProps
},
) {
const disabled = options.disabled || htmlProps["aria-disabled"];
const hasPressedMouse = React.useRef(false);

const onKeyDownRef = useLiveRef(htmlOnKeyDown);
const onClickRef = useLiveRef(htmlOnClick);
const onMouseDownRef = useLiveRef(htmlOnMouseDown);

// Reference:
// https://github.com/chakra-ui/chakra-ui/blob/83eec5b140bd9a69821d8e4df3e69bff0768dcca/packages/menu/src/use-menu.ts#L228-L253
Expand All @@ -35,47 +48,85 @@ export const useSelect = createHook<SelectOptions, SelectHTMLProps>({
onKeyDownRef.current?.(event);
if (event.defaultPrevented) return;

// setTimeout on show prevents scroll jump on ArrowUp & ArrowDown
const first = () => {
if (!options.visible) options.show && setTimeout(options.show);
if (!options.selectedValue) options.first?.();
};
const last = () => {
if (!options.visible) options.show && setTimeout(options.show);
if (!options.selectedValue) options.last?.();
};

const keyMap = {
Enter: first,
" ": first,
ArrowUp: last,
ArrowDown: first,
};

const action = keyMap[event.key as keyof typeof keyMap];
action?.();
if (event.key === "Escape") {
// Doesn't prevent default on Escape, otherwise we can't close
// dialogs when MenuButton is focused
options.hide?.();
} else if (!disabled) {
const keyMap = {
Enter: options.first,
" ": options.first,
ArrowUp: options.last,
ArrowDown: options.first,
};

const action = keyMap[event.key as keyof typeof keyMap];
if (action) {
event.preventDefault();
event.stopPropagation();

// setTimeout prevents scroll jump
options.show && setTimeout(options.show);
if (!options.selectedValue) action();
return;
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
options.visible,
disabled,
options.show,
options.last,
options.hide,
options.first,
options.values,
options.last,
options.selectedValue,
options.setSelectedValue,
],
);

const onMouseDown = React.useCallback((event: React.MouseEvent) => {
// If the Select has been clicked using mouse or keyboard. On mouse click,
// we don't automatically focus the first menu item.
hasPressedMouse.current = true;
onMouseDownRef.current?.(event);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onClick = React.useCallback(
(event: React.MouseEvent) => {
onClickRef.current?.(event);
if (event.defaultPrevented) return;

options.toggle?.();

// Focus the SelectPopover when it's opened with mouse click.
if (hasPressedMouse.current && !options.visible) {
options.move?.(null);
}
hasPressedMouse.current = false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options.show, options.toggle, options.visible, options.move],
);

return {
"aria-haspopup": options.menuRole,
onClick,
onMouseDown,
onKeyDown: callAllHandlers(
onKeyDown,
onCharacterPress(handleCharacterPress(options)),
),
...htmlProps,
};
},

useComposeOptions(options) {
return {
...options,
// Toggling is handled by Select OnClick above
toggle: noop,
};
},
});

export const Select = createComponent({
Expand Down Expand Up @@ -105,6 +156,8 @@ const handleCharacterPress = (options: SelectOptions) => (
if (nextItem) options.setSelectedValue(nextItem);
};

const noop = () => {};

export type SelectOptions = PopoverDisclosureOptions &
SelectStateReturn & {
/**
Expand Down
10 changes: 3 additions & 7 deletions src/select/SelectListState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,11 @@ import {
export function useSelectListState(
initialState: SealedInitialState<SelectListInitialState> = {},
): SelectListStateReturn {
const {
currentId = null,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats because I removed this !

Now by default current id will be on the first tabbable element as programmed in composite.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will only affect the currentValue not the selectedValue.

If the selectedValue is changed, currentId will change on useEffect when visible
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it!

orientation = "vertical",
loop = true,
...sealed
} = useSealedState(initialState);
const { orientation = "vertical", loop = true, ...sealed } = useSealedState(
initialState,
);

const composite = useCompositeState({
currentId,
orientation,
loop,
...sealed,
Expand Down
12 changes: 4 additions & 8 deletions src/select/SelectPopoverState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,10 @@ export function useSelectPopoverState(
const visible = popover.visible;

React.useEffect(() => {
if (visible && selectedId) {
move?.(selectedId);
}

if (!visible) {
// We need to reset select.moves
reset();
}
if (visible && selectedId) move?.(selectedId);

// We need to reset select.moves
if (!visible) reset();
}, [visible, reset, selectedId, move]);

return {
Expand Down
7 changes: 3 additions & 4 deletions src/select/__tests__/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,24 @@ describe("Select", () => {
});

it("should behave properly with default selected", () => {
render(<SelectComponent selectedValue={"orange"} />);
render(<SelectComponent selectedValue="Orange" />);

const popover = screen.getByTestId("popover");
const popoverButton = screen.getByRole("button", {
name: /fruit/i,
});

expect(popoverButton).toHaveTextContent(/orange/i);
expect(popoverButton).toHaveTextContent(/Orange/i);
expect(popover).not.toBeVisible();

press.Tab();
press.ArrowDown();
jest.runAllTimers();
expect(popover).toBeVisible();

press.Tab();
press.Enter();
expect(popover).not.toBeVisible();
expect(popoverButton).toHaveTextContent(/orange/i);
expect(popoverButton).toHaveTextContent(/Orange/i);
});

test("typeahead should work properly when popover is not open", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/select/__tests__/__snapshots__/Select.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ exports[`Select should render correctly 1`] = `
Select a fruit
</button>
<div
aria-activedescendant="select-apple"
anuraghazra marked this conversation as resolved.
Show resolved Hide resolved
aria-label="Fruits"
data-dialog="true"
data-testid="popover"
Expand All @@ -24,6 +25,7 @@ exports[`Select should render correctly 1`] = `
tabindex="0"
>
<div
aria-selected="true"
id="select-apple"
role="option"
tabindex="-1"
Expand Down