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

Allow dropdown state to be controlled #280

Merged
merged 2 commits into from
Nov 21, 2024
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
93 changes: 59 additions & 34 deletions src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,34 @@ limitations under the License.
import { describe, expect, it } from "vitest";
import { composeStories } from "@storybook/react";
import * as stories from "./Dropdown.stories";
import { act, render, waitFor } from "@testing-library/react";
import React from "react";
import { userEvent } from "@storybook/test";
import { render, screen } from "@testing-library/react";
import React, { FC, useMemo, useState } from "react";
import { Dropdown } from "./Dropdown";
import userEvent from "@testing-library/user-event";

const { Default, WithHelpLabel, WithError, WithDefaultValue } =
composeStories(stories);

const ControlledDropdown: FC = () => {
const [value, setValue] = useState("1");
const values = useMemo<[string, string][]>(
() => [
["1", "Option 1"],
["2", "Option 2"],
],
[],
);
return (
<Dropdown
value={value}
onValueChange={setValue}
values={values}
placeholder=""
label="Label"
/>
);
};

describe("Dropdown", () => {
it("renders a Default dropdown", () => {
const { container } = render(<Default />);
Expand All @@ -42,61 +63,65 @@ describe("Dropdown", () => {
expect(container).toMatchSnapshot();
});
it("can be opened", async () => {
const user = userEvent.setup();
const { getByRole, container } = render(<Default />);
await act(async () => {
await userEvent.click(getByRole("combobox"));
});
await user.click(getByRole("combobox"));
expect(container).toMatchSnapshot();
});
it("can select a value", async () => {
const user = userEvent.setup();
const { getByRole, container } = render(<Default />);
await act(async () => {
await userEvent.click(getByRole("combobox"));
});
await user.click(getByRole("combobox"));

await waitFor(() =>
expect(getByRole("option", { name: "Option 2" })).toBeVisible(),
);
expect(getByRole("option", { name: "Option 2" })).toBeVisible();

await act(async () => {
await userEvent.click(getByRole("option", { name: "Option 2" }));
});
await user.click(getByRole("option", { name: "Option 2" }));

expect(getByRole("combobox")).toHaveTextContent("Option 2");

await act(async () => {
await userEvent.click(getByRole("combobox"));
});
await user.click(getByRole("combobox"));

await waitFor(() =>
expect(getByRole("option", { name: "Option 2" })).toHaveAttribute(
"aria-selected",
"true",
),
expect(getByRole("option", { name: "Option 2" })).toHaveAttribute(
"aria-selected",
"true",
);

// Option 2 should be selected
expect(container).toMatchSnapshot();
});
it("can use keyboard shortcuts", async () => {
const user = userEvent.setup();
const { getByRole } = render(<Default />);

await act(async () => userEvent.type(getByRole("combobox"), "{arrowdown}"));
await waitFor(() =>
expect(getByRole("combobox")).toHaveAttribute("aria-expanded", "true"),
);
// arrowdown seems to already select Option 1... in real browsers this
// doesn't happen. Maybe it's a user-event thing? arrowup just opens the
// dropdown as we would expect.
await user.type(getByRole("combobox"), "{arrowup}");
expect(getByRole("combobox")).toHaveAttribute("aria-expanded", "true");

await act(async () => userEvent.keyboard("{arrowdown}"));
await user.keyboard("{arrowdown}");
expect(getByRole("option", { name: "Option 1" })).toHaveFocus();

await act(async () => userEvent.keyboard("{End}"));
await user.keyboard("{End}");
expect(getByRole("option", { name: "Option 3" })).toHaveFocus();

await act(async () => userEvent.keyboard("{Enter}"));
await user.keyboard("{Enter}");

await waitFor(() => {
expect(getByRole("combobox")).toHaveTextContent("Option 3");
expect(getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
expect(getByRole("combobox")).toHaveTextContent("Option 3");
expect(getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("supports controlled operation", async () => {
const user = userEvent.setup();
render(<ControlledDropdown />);

expect(screen.getByRole("option", { name: "Option 1" })).toHaveAttribute(
"aria-selected",
"true",
);
await user.click(screen.getByRole("option", { name: "Option 2" }));
expect(screen.getByRole("option", { name: "Option 2" })).toHaveAttribute(
"aria-selected",
"true",
);
});
});
85 changes: 38 additions & 47 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import React, {
useRef,
useState,
KeyboardEvent,
useMemo,
} from "react";

import classNames from "classnames";
Expand All @@ -43,7 +44,11 @@ type DropdownProps = {
*/
className?: string;
/**
* The default value of the dropdown.
* The controlled value of the dropdown.
*/
value?: string;
/**
* The default value of the dropdown, used when uncontrolled.
*/
defaultValue?: string;
/**
Expand Down Expand Up @@ -86,34 +91,46 @@ export const Dropdown = forwardRef<HTMLButtonElement, DropdownProps>(
helpLabel,
onValueChange,
error,
value: controlledValue,
defaultValue,
values,
...props
},
ref,
) {
const [state, setState] = useInitialState(
values,
placeholder,
defaultValue,
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const value = controlledValue ?? uncontrolledValue;
const text = useMemo(
() =>
value === undefined
? placeholder
: (values.find(([v]) => v === value)?.[1] ?? placeholder),
[value, values, placeholder],
);

const setValue = useCallback(
(value: string) => {
setUncontrolledValue(value);
onValueChange?.(value);
},
[setUncontrolledValue, onValueChange],
);

const [open, setOpen, dropdownRef] = useOpen();
const { listRef, onComboboxKeyDown, onOptionKeyDown } = useKeyboardShortcut(
open,
setOpen,
setState,
setValue,
);

const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
// Focus the button when the value is set
// Test if the value is undefined to avoid focusing on the first render
if (state.value !== undefined) {
buttonRef.current?.focus();
}
}, [state]);
if (value !== undefined) buttonRef.current?.focus();
}, [value]);

const hasPlaceholder = state.text === placeholder;
const hasPlaceholder = text === placeholder;
const buttonClasses = classNames({
[styles.placeholder]: hasPlaceholder,
});
Expand Down Expand Up @@ -158,7 +175,7 @@ export const Dropdown = forwardRef<HTMLButtonElement, DropdownProps>(
onKeyDown={onComboboxKeyDown}
{...props}
>
{state.text}
{text}
<ChevronDown width="24" height="24" />
</button>
<div className={borderClasses} />
Expand All @@ -169,17 +186,16 @@ export const Dropdown = forwardRef<HTMLButtonElement, DropdownProps>(
role="listbox"
className={styles.content}
>
{values.map(([value, text]) => (
{values.map(([v, text]) => (
<DropdownItem
key={value}
key={v}
isDisplayed={open}
isSelected={state.value === value}
isSelected={value === v}
onClick={() => {
setOpen(false);
setState({ value, text });
onValueChange?.(value);
setValue(v);
}}
onKeyDown={(e) => onOptionKeyDown(e, value, text)}
onKeyDown={(e) => onOptionKeyDown(e, v)}
>
{text}
</DropdownItem>
Expand Down Expand Up @@ -272,31 +288,6 @@ function useOpen(): [
return [open, setOpen, ref];
}

/**
* A hook to manage the initial state of the dropdown.
* @param values - The values of the dropdown.
* @param placeholder - The placeholder text.
* @param defaultValue - The default value of the dropdown.
*/
function useInitialState(
values: [string, string][],
placeholder: string,
defaultValue?: string,
) {
return useState(() => {
const defaultTuple = {
value: undefined,
text: placeholder,
};
if (!defaultValue) return defaultTuple;

const foundTuple = values.find(([value]) => value === defaultValue);
return foundTuple
? { value: foundTuple[0], text: foundTuple[1] }
: defaultTuple;
});
}

/**
* A hook to manage the keyboard shortcuts of the dropdown.
* @param open - the dropdown open state.
Expand All @@ -306,7 +297,7 @@ function useInitialState(
function useKeyboardShortcut(
open: boolean,
setOpen: Dispatch<SetStateAction<boolean>>,
setValue: ({ text, value }: { text: string; value: string }) => void,
setValue: (value: string) => void,
) {
const listRef = useRef<HTMLUListElement>(null);
const onComboboxKeyDown = useCallback(
Expand Down Expand Up @@ -348,15 +339,15 @@ function useKeyboardShortcut(
);

const onOptionKeyDown = useCallback(
(evt: KeyboardEvent, value: string, text: string) => {
(evt: KeyboardEvent, value: string) => {
const { key, altKey } = evt;
evt.stopPropagation();
evt.preventDefault();

switch (key) {
case "Enter":
case " ": {
setValue({ text, value });
setValue(value);
setOpen(false);
break;
}
Expand All @@ -373,7 +364,7 @@ function useKeyboardShortcut(
}
case "ArrowUp": {
if (altKey) {
setValue({ text, value });
setValue(value);
setOpen(false);
} else {
const currentFocus = document.activeElement;
Expand Down
Loading