Skip to content

Commit

Permalink
Allow dropdown state to be controlled
Browse files Browse the repository at this point in the history
Controlling the dropdown value via its props is often more convenient and enables the value to be changed without user interaction.
  • Loading branch information
robintown committed Nov 19, 2024
1 parent 89c5c0e commit 2f41eaf
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 49 deletions.
41 changes: 39 additions & 2 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 { act, render, screen, waitFor } from "@testing-library/react";
import React, { FC, useMemo, useState } from "react";
import { userEvent } from "@storybook/test";
import { Dropdown } from "./Dropdown";

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 Down Expand Up @@ -99,4 +120,20 @@ describe("Dropdown", () => {
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 act(() =>
user.click(screen.getByRole("option", { name: "Option 2" })),
);
expect(screen.getByRole("option", { name: "Option 2" })).toHaveAttribute(
"aria-selected",
"true",
);
});
});
86 changes: 39 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,17 @@ 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);
setUncontrolledValue(v);
setValue?.(v);
}}
onKeyDown={(e) => onOptionKeyDown(e, value, text)}
onKeyDown={(e) => onOptionKeyDown(e, v)}
>
{text}
</DropdownItem>
Expand Down Expand Up @@ -272,31 +289,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 +298,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 +340,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 +365,7 @@ function useKeyboardShortcut(
}
case "ArrowUp": {
if (altKey) {
setValue({ text, value });
setValue(value);
setOpen(false);
} else {
const currentFocus = document.activeElement;
Expand Down

0 comments on commit 2f41eaf

Please sign in to comment.