diff --git a/.changeset/giant-coats-fail.md b/.changeset/giant-coats-fail.md new file mode 100644 index 000000000..85645e0d1 --- /dev/null +++ b/.changeset/giant-coats-fail.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-dropdown": minor +--- + +Add multiple selection support to `Combobox` diff --git a/.changeset/mean-eagles-explode.md b/.changeset/mean-eagles-explode.md new file mode 100644 index 000000000..0adae0bb6 --- /dev/null +++ b/.changeset/mean-eagles-explode.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-pill": minor +--- + +Add `tabIndex` prop to customize keyboard focusability of clickable pills diff --git a/__docs__/wonder-blocks-dropdown/combobox.stories.tsx b/__docs__/wonder-blocks-dropdown/combobox.stories.tsx index ee5902d90..ef390051d 100644 --- a/__docs__/wonder-blocks-dropdown/combobox.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/combobox.stories.tsx @@ -170,3 +170,81 @@ export const Disabled = { value: "pear", }, }; + +/** + * Combobox supports multiple selection. This means that more than one element + * can be selected from the listbox at a time. In this example, we show how this + * is done by using an array of strings as the value and setting the + * `selectionType` prop to "multiple". + * + * To navigate using the keyboard, use: + * - Arrow keys (`up`, `down`) to navigate through the listbox. + * - `Enter` to select an item. + * - Arrow keys (`left`, `right`) to navigate through the selected items. + */ +export const MultipleSelection = { + render: function Render(args: PropsFor) { + const [value, setValue] = React.useState(args.value); + + return ( + { + setValue(newValue); + action("onChange")(newValue); + }} + /> + ); + }, + args: { + children: items, + value: ["pear", "grape"], + selectionType: "multiple", + }, + parameters: { + chromatic: { + // we don't need screenshots because this story only tests behavior. + disableSnapshot: true, + }, + }, +}; + +/** + * This example shows how to use the multi-select `Combobox` component in + * controlled mode. + */ +export const ControlledMultilpleCombobox: Story = { + name: "Controlled Multi-select Combobox (opened state)", + render: function Render(args: PropsFor) { + const [opened, setOpened] = React.useState(args.opened); + const [value, setValue] = React.useState(args.value); + + return ( + { + setOpened(!opened); + action("onToggle")(); + }} + onChange={(newValue) => { + setValue(newValue); + action("onChange")(newValue); + }} + value={value} + /> + ); + }, + args: { + children: items, + opened: true, + value: ["pear", "grape"], + selectionType: "multiple", + }, + decorators: [ + (Story): React.ReactElement> => ( + {Story()} + ), + ], +}; diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 15a3e903c..cabfabceb 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -22,6 +22,7 @@ "@khanacademy/wonder-blocks-icon": "^4.1.4", "@khanacademy/wonder-blocks-layout": "^2.2.0", "@khanacademy/wonder-blocks-modal": "^5.1.10", + "@khanacademy/wonder-blocks-pill": "^2.4.6", "@khanacademy/wonder-blocks-search-field": "^2.2.24", "@khanacademy/wonder-blocks-timing": "^5.0.1", "@khanacademy/wonder-blocks-tokens": "^2.0.0", diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx index e9f8e3cfa..37b106bbb 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx @@ -249,4 +249,293 @@ describe("Combobox", () => { // Assert expect(screen.getByRole("combobox")).not.toHaveFocus(); }); + + describe("multiple selection", () => { + it("should render the combobox", () => { + // Arrange + + // Act + doRender( + + + + + , + ); + + // Assert + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should assign the correct values when passed in", () => { + // Arrange + + // Act + doRender( + + + + + , + ); + + // Assert + const selectedOptions = screen.getAllByRole("option", { + hidden: true, + selected: true, + }); + expect(selectedOptions).toHaveLength(2); + //Also verify that the selected options are correct. + expect(selectedOptions[0]).toHaveTextContent("option 2"); + expect(selectedOptions[1]).toHaveTextContent("option 3"); + }); + + it("should display the correct pill when one value is selected", () => { + // Arrange + + // Act + doRender( + + + + + , + ); + + // Assert + // pills + expect(screen.getByTestId("test-pill-0")).toHaveTextContent( + "option 2", + ); + }); + + it("should use the correct label for a selected item (pills)", () => { + // Arrange + + // Act + doRender( + + + + + , + ); + + // Assert + // pills + expect( + screen.getByRole("button", {name: "Remove option 2"}), + ).toBeInTheDocument(); + }); + + it("should call the onChange callback when the value changes", async () => { + // Arrange + const onChange = jest.fn(); + const userEvent = doRender( + + + + + , + ); + + await userEvent.click( + screen.getByRole("button", {name: /toggle listbox/i}), + ); + await screen.findByRole("listbox", {hidden: true}); + + // Act + const options = screen.getAllByRole("option", { + hidden: true, + }); + await userEvent.click(options[2]); + + // Assert + expect(onChange).toHaveBeenCalledWith(["option2", "option3"]); + }); + + it("should remove visual focus from the listbox when navigating to the selected pills", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Focus the combobox + await userEvent.tab(); + + // Act + // Navigate to the last pill in the stack + await userEvent.keyboard("{ArrowLeft}"); + + // Assert + expect(screen.getByRole("combobox")).not.toHaveAttribute( + "aria-activedescendant", + ); + }); + + it("should remove the last pill using the left arrow key", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Focus the combobox + await userEvent.tab(); + // Navigate to the last pill in the stack + await userEvent.keyboard("{ArrowLeft}"); + + // Act + await userEvent.keyboard("{Enter}"); + + // Assert + expect( + screen.getAllByRole("button", {name: /remove/i}), + ).toHaveLength(1); + expect( + screen.getByRole("button", {name: "Remove option 1"}), + ).toBeInTheDocument(); + }); + + it("should remove the first pill using the right arrow key", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Focus the combobox + await userEvent.tab(); + // Navigate to the last pill in the stack + await userEvent.keyboard("{ArrowRight}"); + + // Act + await userEvent.keyboard("{Enter}"); + + // Assert + expect( + screen.getAllByRole("button", {name: /remove/i}), + ).toHaveLength(1); + expect( + screen.getByRole("button", {name: "Remove option 2"}), + ).toBeInTheDocument(); + }); + + it("should remove a pill using the delete key", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Focus the combobox + await userEvent.tab(); + // Navigate to the last pill in the stack + await userEvent.keyboard("{ArrowLeft}"); + + // Act + await userEvent.keyboard("{Backspace}"); + + // Assert + expect( + screen.getAllByRole("button", {name: /remove/i}), + ).toHaveLength(1); + }); + + it("should remove all the pills using the delete key", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Focus the combobox + await userEvent.tab(); + + // Act + await userEvent.keyboard("{Backspace}"); + await userEvent.keyboard("{Backspace}"); + + // Assert + expect( + screen.queryAllByRole("button", {name: /remove/i}), + ).toHaveLength(0); + }); + + it("should remove a pill by pressing it", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Act + await userEvent.click( + screen.getByRole("button", {name: "Remove option 1"}), + ); + + // Assert + expect( + screen.getAllByRole("button", {name: /remove/i}), + ).toHaveLength(1); + expect( + screen.getByRole("button", {name: "Remove option 2"}), + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/wonder-blocks-dropdown/src/components/combobox-multiple-selection.tsx b/packages/wonder-blocks-dropdown/src/components/combobox-multiple-selection.tsx new file mode 100644 index 000000000..513bb8e1f --- /dev/null +++ b/packages/wonder-blocks-dropdown/src/components/combobox-multiple-selection.tsx @@ -0,0 +1,99 @@ +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import Pill from "@khanacademy/wonder-blocks-pill"; +import {color, font, spacing} from "@khanacademy/wonder-blocks-tokens"; +import xIcon from "@phosphor-icons/core/regular/x.svg"; + +type Props = { + /** + * Whether the combobox is disabled. + */ + disabled?: boolean; + /** + * The index of the focused item in the pills group. + */ + focusedMultiSelectIndex: number; + /** + * The unique identifier for the selected items. + */ + id: string; + /** + * The list of labels for the selected items. + */ + labels: Array; + /** + * Function to remove a selected item. + */ + onRemove: (value: string) => void; + /** + * The list of selected items, where each item represents the value of the + * selected option. + */ + selected: Array; + /** + * The testId prefix used for the pills. + */ + testId?: string; +}; + +/** + * Renders the selected items as pills that are horizontally stacked before + * the input element. + */ +export const MultipleSelection = React.memo(function SelectedPills({ + disabled, + focusedMultiSelectIndex, + id, + labels, + onRemove, + selected, + testId, +}: Props) { + return ( + <> + {selected.map((value, index) => { + const label = labels[index] as string; + const focused = index === focusedMultiSelectIndex; + const uniqueId = id + index; + + return ( + onRemove(value)} + > + <> + {label} + {!disabled && ( + + )} + + + ); + })} + + ); +}); + +const styles = StyleSheet.create({ + pill: { + fontSize: font.size.small, + justifyContent: "space-between", + alignItems: "center", + marginBlockStart: spacing.xxxSmall_4, + marginInlineEnd: spacing.xxxSmall_4, + paddingInlineEnd: spacing.xxxSmall_4, + }, + pillFocused: { + outline: `1px solid ${color.blue}`, + }, +}); diff --git a/packages/wonder-blocks-dropdown/src/components/combobox.tsx b/packages/wonder-blocks-dropdown/src/components/combobox.tsx index 956987e94..f310ddb7c 100644 --- a/packages/wonder-blocks-dropdown/src/components/combobox.tsx +++ b/packages/wonder-blocks-dropdown/src/components/combobox.tsx @@ -1,7 +1,8 @@ -import * as React from "react"; import {StyleSheet} from "aphrodite"; +import * as React from "react"; import caretDownIcon from "@phosphor-icons/core/regular/caret-down.svg"; + import { StyleType, useUniqueIdWithMock, @@ -11,10 +12,12 @@ import {TextField} from "@khanacademy/wonder-blocks-form"; import IconButton from "@khanacademy/wonder-blocks-icon-button"; import {border, color, spacing} from "@khanacademy/wonder-blocks-tokens"; -import DropdownPopper from "./dropdown-popper"; -import Listbox from "./listbox"; import {useListbox} from "../hooks/use-listbox"; +import {useMultipleSelection} from "../hooks/use-multiple-selection"; import {MaybeValueOrValues, OptionItemComponent} from "../util/types"; +import {MultipleSelection} from "./combobox-multiple-selection"; +import DropdownPopper from "./dropdown-popper"; +import Listbox from "./listbox"; type Props = { /** @@ -42,22 +45,6 @@ type Props = { */ onChange?: (value: MaybeValueOrValues) => void; - /** - * A reference to the element that describes the listbox. - */ - "aria-labelledby"?: string; - - /** - * Takes as its value the id of the currently focused element within the - * option items collection. - * - * Instead of the screen reader moving focus between owned elements, - * aria-activedescendant is used to refer to the currently active element, - * informing assistive technology users of the currently active element when - * focused. - */ - "aria-activedescendant"?: string; - /** * Whether the combobox is disabled. * @@ -136,12 +123,19 @@ export default function Combobox({ opened, placeholder, selectionType = "single", + testId, value = "", }: Props) { - const [inputValue, setInputValue] = React.useState((value as string) || ""); + // NOTE: Clear input value if we are in multi-select mode. The selected + // values are handled as individual Pill instances. + const initialValue = typeof value === "string" ? value : ""; + const [inputValue, setInputValue] = React.useState(initialValue); const ids = useUniqueIdWithMock("combobox"); - const uniqueId = id ?? ids.get("id"); + const uniqueId = id ?? ids.get("listbox"); + // Ref to the combobox input element. const comboboxRef = React.useRef(null); + // Ref to the top-level node of the combobox. + const rootNodeRef = React.useRef(null); // Determines whether the component is controlled or not. const [open, setOpen] = React.useState(opened ?? false); const isControlled = opened !== undefined; @@ -155,17 +149,19 @@ export default function Combobox({ const { focusedIndex, + isListboxFocused, setFocusedIndex, handleKeyDown, handleFocus, handleBlur, selected, + setSelected, renderList, } = useListbox({ children, disabled, id: uniqueId, - value, + value: valueState, // Allows pressing the space key in the input element without selecting // an item from the listbox. disableSpaceSelection: true, @@ -212,7 +208,11 @@ export default function Combobox({ updateOpenState(false); } } else if (Array.isArray(selected)) { - // TODO(WB-1676): Handle multi-select selected values + // If the value changes, update the parent component. + if (selected !== valueState) { + setInputValue(""); + onChange?.(selected); + } } }, [ children, @@ -225,6 +225,15 @@ export default function Combobox({ valueState, ]); + const { + focusedMultiSelectIndex, + handleKeyDown: handleMultipleSelectionKeyDown, + } = useMultipleSelection({ + inputValue, + selected, + setSelected, + }); + const focusOnFilteredItem = React.useCallback( (value: string) => { const lowercasedSearchText = value.normalize("NFC").toLowerCase(); @@ -247,43 +256,103 @@ export default function Combobox({ [children, setFocusedIndex], ); + /** + * Handles specific keyboard events for the combobox. + */ const onKeyDown = (event: React.KeyboardEvent) => { - // Propagate the event to useListbox to handle keyboard navigation - handleKeyDown(event); + const {key} = event; // Open the listbox under the following conditions: const conditionsToOpen = // The user presses specific keys - event.key === "ArrowDown" || - event.key === "ArrowUp" || - event.key === "Backspace" || + key === "ArrowDown" || + key === "ArrowUp" || + key === "Backspace" || // The user starts typing - event.key.length === 1; + key.length === 1; if (!openState && conditionsToOpen) { updateOpenState(true); } - // Close only the dropdown, not other elements that are - // listening for an escape press - if (event.key === "Escape" && openState) { + /** + * Handle keyboard navigation for multi-select combobox + */ + if (key === "ArrowLeft" || key === "ArrowRight") { + setFocusedIndex(-1); + } + // Propagate the event to useMultipleSelection to handle keyboard + // navigation + handleMultipleSelectionKeyDown(event); + + /** + * Shared keyboard navigation for single and multi-select combobox + */ + + // Close only the dropdown, not other elements that are listening for an + // escape press + if (key === "Escape" && openState) { event.stopPropagation(); updateOpenState(false); } + + // Propagate the event to useListbox to handle keyboard navigation + handleKeyDown(event); }; + // The labels of the selected values. + const selectedLabels = React.useMemo( + () => + children + .filter((item) => selected?.includes(item.props.value)) + .map((item) => item.props.label as string), + [children, selected], + ); + + /** + * Handles the click event on a pill to remove it from the list of selected + * items. + */ + const handleOnRemove = React.useCallback( + (value: string) => { + const selectedValues = selected as Array; + // Remove the selected item from the list of selected items. + const newValues = selectedValues.filter( + (selectedValue) => selectedValue !== value, + ); + + setSelected(newValues); + }, + [selected, setSelected], + ); + return ( <> { - if (!openState) { - updateOpenState(true); - } + updateOpenState(true); }} - style={styles.wrapper} + ref={rootNodeRef} + style={[styles.wrapper, isListboxFocused && styles.focused]} > + {/* TODO(WB-1676.2): Add aria-live region to announce combobox states */} + + {/* Multi-select pills display before the input (if options are selected) */} + {selectionType === "multiple" && Array.isArray(selected) && ( + } + onRemove={handleOnRemove} + disabled={disabled} + testId={testId} + /> + )} { setInputValue(value); @@ -291,16 +360,12 @@ export default function Combobox({ }} disabled={disabled} onFocus={() => { - if (!openState) { - updateOpenState(true); - } + updateOpenState(true); handleFocus(); }} placeholder={placeholder} onBlur={() => { - if (openState) { - updateOpenState(false); - } + updateOpenState(false); handleBlur(); }} aria-controls={openState ? uniqueId : undefined} @@ -312,11 +377,12 @@ export default function Combobox({ } aria-expanded={openState} ref={comboboxRef} - // We don't want the browser to suggest autocompletions as the - // combobox is already providing suggestions. + // We don't want the browser to suggest autocompletions as + // the combobox is already providing suggestions. autoComplete="off" role="combobox" /> + @@ -343,7 +410,7 @@ export default function Combobox({ alignment="left" // NOTE: We need to cast comboboxRef to HTMLElement because // the DropdownPopper component expects an HTMLElement. - referenceElement={comboboxRef?.current as HTMLElement} + referenceElement={rootNodeRef?.current as HTMLElement} > {(isReferenceHidden) => ( {renderList} @@ -372,7 +442,40 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", width: "100%", + maxWidth: "100%", + flexWrap: "wrap", + // The following styles are to emulate the input styles + background: color.white, + borderRadius: border.radius.medium_4, + border: `solid 1px ${color.offBlack16}`, + paddingInline: spacing.xSmall_8, + }, + focused: { + background: color.white, + border: `1px solid ${color.blue}`, + }, + /** + * Combobox input styles + */ + combobox: { + // reset input styles + appearance: "none", + background: "none", + border: "none", + outline: "none", + padding: 0, + minWidth: spacing.xxxSmall_4, + width: "auto", + display: "inline-grid", + gridArea: "1 / 2", + ":focus-visible": { + outline: "none", + border: "none", + }, }, + /** + * Listbo custom styles + */ listbox: { backgroundColor: color.white, borderRadius: border.radius.medium_4, @@ -387,6 +490,9 @@ const styles = StyleSheet.create({ pointerEvents: "none", visibility: "hidden", }, + /** + * Arrow button styles + */ button: { position: "absolute", right: spacing.xxxSmall_4, diff --git a/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx b/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx index 97f3dd984..480dd23b0 100644 --- a/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx +++ b/packages/wonder-blocks-dropdown/src/hooks/use-listbox.tsx @@ -132,13 +132,16 @@ export function useListbox({ return; case "Enter": case " ": - // Only handle space if the listbox is focused - if (key === " " && disableSpaceSelection) { + if ( + // No item is focused + focusedIndex < 0 || + // Only handle space if the listbox is focused + (key === " " && disableSpaceSelection) + ) { return; } // Prevent form submission event.preventDefault(); - selectOption(focusedIndex); return; } @@ -234,6 +237,7 @@ export function useListbox({ // list of options renderList, // selected value(s) + setSelected, selected, // handlers handleKeyDown, diff --git a/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx b/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx new file mode 100644 index 000000000..780aa9e8c --- /dev/null +++ b/packages/wonder-blocks-dropdown/src/hooks/use-multiple-selection.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import {MaybeValueOrValues} from "../util/types"; + +type Props = { + /** + * The list of selected items, where each item represents the value of the + * selected option. + */ + selected: MaybeValueOrValues; + /** + * Function to set the selected items. + */ + setSelected: (value: MaybeValueOrValues) => void; + /** + * The current value of the input. + */ + inputValue: string; +}; + +/** + * Hook for managing the state of the multi-select values in the combobox. + * + * It manages keyboard navigation and selection management for the multi-select + * selected values. + */ +export function useMultipleSelection({ + inputValue, + selected, + setSelected, +}: Props) { + // Index of the currently focused pill in the multi-select combobox. + const [focusedMultiSelectIndex, setFocusedMultiSelectIndex] = + React.useState(-1); + + /** + * Keyboard specific behaviors for the multi-select combobox. + */ + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const {key} = event; + + // only works for multi-select mode + if (!Array.isArray(selected) || selected.length === 0) { + return; + } + + if (key === "ArrowLeft") { + setFocusedMultiSelectIndex((prev) => { + const newIndex = prev - 1; + return newIndex < 0 ? selected?.length - 1 : newIndex; + }); + } + + if (key === "ArrowRight") { + setFocusedMultiSelectIndex((prev) => { + const newIndex = prev + 1; + return newIndex >= selected?.length ? 0 : newIndex; + }); + } + + // Remove the last selected option with the backspace key. + if (inputValue === "" && key === "Backspace") { + let newSelected = []; + if (focusedMultiSelectIndex < 0) { + // remove last selection (if there's any) + newSelected = selected?.slice(0, -1); + } else { + // remove focused pill + newSelected = selected.filter( + (_, index) => index !== focusedMultiSelectIndex, + ); + } + + setSelected(newSelected); + setFocusedMultiSelectIndex(-1); + } + + if (focusedMultiSelectIndex >= 0 && key === "Enter") { + const newSelected = selected?.filter( + (_, index) => index !== focusedMultiSelectIndex, + ); + + // remove current selected option + setSelected(newSelected); + setFocusedMultiSelectIndex(-1); + } + + // Clear the focused pill index when navigating through the listbox, so + // the visual focus is back to the listbox. + if (key === "ArrowDown" || key === "ArrowUp") { + setFocusedMultiSelectIndex(-1); + } + + // Clear the focused pill index when pressing the escape key. + if (key === "Escape") { + setFocusedMultiSelectIndex(-1); + } + }, + [focusedMultiSelectIndex, inputValue, selected, setSelected], + ); + + return { + focusedMultiSelectIndex, + handleKeyDown, + }; +} diff --git a/packages/wonder-blocks-dropdown/tsconfig-build.json b/packages/wonder-blocks-dropdown/tsconfig-build.json index bf2610adc..7ccaec5d5 100644 --- a/packages/wonder-blocks-dropdown/tsconfig-build.json +++ b/packages/wonder-blocks-dropdown/tsconfig-build.json @@ -13,6 +13,7 @@ {"path": "../wonder-blocks-icon/tsconfig-build.json"}, {"path": "../wonder-blocks-layout/tsconfig-build.json"}, {"path": "../wonder-blocks-modal/tsconfig-build.json"}, + {"path": "../wonder-blocks-pill/tsconfig-build.json"}, {"path": "../wonder-blocks-search-field/tsconfig-build.json"}, {"path": "../wonder-blocks-timing/tsconfig-build.json"}, {"path": "../wonder-blocks-tokens/tsconfig-build.json"}, diff --git a/packages/wonder-blocks-pill/src/components/pill.tsx b/packages/wonder-blocks-pill/src/components/pill.tsx index 33debf326..8f2bbf4b9 100644 --- a/packages/wonder-blocks-pill/src/components/pill.tsx +++ b/packages/wonder-blocks-pill/src/components/pill.tsx @@ -66,14 +66,14 @@ type Props = AriaProps & { * Custom styles to add to this pill component. */ style?: StyleType; - /** - * Optional test ID for e2e testing. - */ - testId?: string; /** * The tab index of the pill (clickable only). */ tabIndex?: number; + /** + * Optional test ID for e2e testing. + */ + testId?: string; }; const PillInner = (props: { @@ -121,8 +121,8 @@ const Pill = React.forwardRef(function Pill( role, onClick, style, - testId, tabIndex, + testId, ...ariaProps } = props;