From 092303f00139373d47fec78046ffaf34e250ae69 Mon Sep 17 00:00:00 2001 From: Urvashi Sharma Date: Wed, 29 Apr 2026 17:58:06 +0530 Subject: [PATCH 1/2] feat: multiselect improvements --- src/components/MultiSelect/MultiSelect.scss | 6 + .../MultiSelect/MultiSelect.stories.tsx | 78 +++++++++++ .../MultiSelect/MultiSelect.test.tsx | 80 ++++++++++++ src/components/MultiSelect/MultiSelect.tsx | 123 +++++++++++++----- 4 files changed, 256 insertions(+), 31 deletions(-) diff --git a/src/components/MultiSelect/MultiSelect.scss b/src/components/MultiSelect/MultiSelect.scss index 6248be80d..a6f82151d 100644 --- a/src/components/MultiSelect/MultiSelect.scss +++ b/src/components/MultiSelect/MultiSelect.scss @@ -93,6 +93,12 @@ $dropdown-max-height: 20rem; } } +.multi-select__empty-state { + color: $colors--theme--text-muted; + margin: 0; + padding: $spv--small $sph--large; +} + .multi-select__dropdown-button { border: 0; margin-bottom: 0; diff --git a/src/components/MultiSelect/MultiSelect.stories.tsx b/src/components/MultiSelect/MultiSelect.stories.tsx index 76fbbb077..f96023f78 100644 --- a/src/components/MultiSelect/MultiSelect.stories.tsx +++ b/src/components/MultiSelect/MultiSelect.stories.tsx @@ -1,7 +1,9 @@ import React from "react"; import { useState } from "react"; +import { Formik } from "formik"; import { Meta, StoryObj } from "@storybook/react"; +import { FormikField } from "../../index"; import { MultiSelect, MultiSelectItem, MultiSelectProps } from "./MultiSelect"; const Template = (props: MultiSelectProps) => { @@ -28,6 +30,13 @@ export default meta; type Story = StoryObj; +const groupedItems = [ + { label: "Almond", value: "almond", group: "Nuts" }, + { label: "Cashew", value: "cashew", group: "Nuts" }, + { label: "Mango", value: "mango", group: "Fruit" }, + { label: "Peach", value: "peach", group: "Fruit" }, +]; + export const CondensedExample: Story = { args: { items: [ @@ -142,3 +151,72 @@ export const HelpText: Story = { ), }, }; + +const FormikControlledSearchAndLifecycleTemplate = () => { + const [selectedItems, setSelectedItems] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [events, setEvents] = useState([]); + + const addEvent = (eventName: string) => { + setEvents((previousEvents) => [eventName, ...previousEvents].slice(0, 6)); + }; + + return ( +
+ {}}> + { + setSearchValue(value); + addEvent(`onSearchChange("${value}")`); + }} + onOpen={() => addEvent("onOpen()")} + onClose={() => addEvent("onClose()")} + onResetSearch={() => addEvent("onResetSearch()")} + emptyMessage="No ingredients found" + /> + +

Callback log:

+
    + {events.map((event, index) => ( +
  • {event}
  • + ))} +
+
+ ); +}; + +export const FormikControlledSearchAndLifecycle: Story = { + render: FormikControlledSearchAndLifecycleTemplate, +}; + +export const EmptyMessage: Story = { + args: { + variant: "search", + items: groupedItems, + emptyMessage: "No matching ingredients.", + placeholder: "Try typing kiwi", + }, +}; + +export const EmptyStateNode: Story = { + args: { + variant: "search", + items: groupedItems, + placeholder: "Try typing kiwi", + emptyState: ( +
+ No ingredient matches. +

Use a broader term.

+
+ ), + }, +}; diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx index 78f6c0407..bb6299bb0 100644 --- a/src/components/MultiSelect/MultiSelect.test.tsx +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -97,6 +97,86 @@ it("can filter option list", async () => { await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(2)); }); +it("supports controlled search input", async () => { + const ControlledMultiSelect = () => { + const [searchValue, setSearchValue] = React.useState(""); + + return ( + + ); + }; + + render(); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.type(screen.getByRole("combobox"), "item"); + + expect(screen.getByRole("combobox")).toHaveValue("item"); + await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(2)); +}); + +it("calls search lifecycle callbacks", async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onResetSearch = jest.fn(); + + render( + , + ); + + await userEvent.click(screen.getByRole("combobox")); + expect(onOpen).toHaveBeenCalledTimes(1); + + await userEvent.type(screen.getByRole("combobox"), "item"); + await userEvent.click(document.body); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(onResetSearch).toHaveBeenCalledTimes(1); + expect(screen.getByRole("combobox")).toHaveValue(""); +}); + +it("renders emptyMessage when no items match", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.type(screen.getByRole("combobox"), "does not exist"); + + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + expect(screen.getByText("No results found")).toBeInTheDocument(); +}); + +it("renders emptyState when no items match", async () => { + render( + No custom items} + />, + ); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.type(screen.getByRole("combobox"), "does not exist"); + + expect(screen.getByText("No custom items")).toBeInTheDocument(); +}); + it("can display a custom dropdown header and footer", async () => { render( ReactNode; dropdownHeader?: ReactNode; dropdownFooter?: ReactNode; + emptyState?: ReactNode; + emptyMessage?: string; showDropdownFooter?: boolean; variant?: "condensed" | "search"; scrollOverflow?: boolean; isSortedAlphabetically?: boolean; hasSelectedItemsFirst?: boolean; id?: string; + searchValue?: string; + onSearchChange?: (value: string) => void; + onOpen?: () => void; + onClose?: () => void; + onResetSearch?: () => void; }; type ValueSet = Set; @@ -54,6 +61,8 @@ type MultiSelectDropdownProps = { onDeselectItem?: (item: MultiSelectItem) => void; onSelectItem?: (item: MultiSelectItem) => void; footer?: ReactNode; + emptyState?: ReactNode; + emptyMessage?: string; groupFn?: GroupFn; sortFn?: SortFn; hasSelectedItemsFirst?: boolean; @@ -98,6 +107,8 @@ export const MultiSelectDropdown: React.FC = ({ onDeselectItem, isOpen, footer, + emptyState, + emptyMessage, sortFn = sortAlphabetically, groupFn = getGroupedItems, hasSelectedItemsFirst = true, @@ -122,6 +133,7 @@ export const MultiSelectDropdown: React.FC = ({ }, [isOpen]); const hasGroup = useMemo(() => items.some((item) => item.group), [items]); + const hasItems = items.length > 0; const groupedItems = useMemo( () => (hasGroup ? groupFn(items) : [{ group: "Ungrouped", items }]), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -147,34 +159,42 @@ export const MultiSelectDropdown: React.FC = ({
{header ? header : null} - {groupedItems.map(({ group, items }) => ( -
- {hasGroup ? ( -
{group}
- ) : null} -
    - {items - .toSorted(sortFn) - .toSorted( - hasSelectedItemsFirst - ? createSortSelectedItems(previouslySelectedItemValues) - : () => 0, - ) - .map((item) => ( -
  • - -
  • - ))} -
-
- ))} + {hasItems + ? groupedItems.map(({ group, items }) => ( +
+ {hasGroup ? ( +
{group}
+ ) : null} +
    + {items + .toSorted(sortFn) + .toSorted( + hasSelectedItemsFirst + ? createSortSelectedItems(previouslySelectedItemValues) + : () => 0, + ) + .map((item) => ( +
  • + +
  • + ))} +
+
+ )) + : (emptyState ?? + (emptyMessage ? ( +

{emptyMessage}

+ ) : null))} {footer ?
{footer}
: null}
@@ -201,18 +221,27 @@ export const MultiSelect: React.FC = ({ disabledItems = [], dropdownHeader, dropdownFooter, + emptyState, + emptyMessage, showDropdownFooter = true, variant = "search", scrollOverflow = false, isSortedAlphabetically = true, hasSelectedItemsFirst = true, id, + searchValue, + onSearchChange, + onOpen, + onClose, + onResetSearch, help, helpClassName, }: MultiSelectProps) => { const buttonRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [filter, setFilter] = useState(""); + const [internalSearchValue, setInternalSearchValue] = useState(""); + const previousOpenState = useRef(isDropdownOpen); + const filter = searchValue ?? internalSearchValue; const [internalSelectedItems, setInternalSelectedItems] = useState< MultiSelectItem[] @@ -220,6 +249,21 @@ export const MultiSelect: React.FC = ({ const selectedItems = externalSelectedItems || internalSelectedItems; const helpId = useId(); + const updateSearchValue = (value: string) => { + if (searchValue === undefined) { + setInternalSearchValue(value); + } + onSearchChange?.(value); + }; + + const resetSearch = () => { + if (!filter.length) { + return; + } + updateSearchValue(""); + onResetSearch?.(); + }; + const updateItems = (newItems: MultiSelectItem[]) => { const uniqueItems = Array.from(new Set(newItems)); setInternalSelectedItems(uniqueItems); @@ -228,6 +272,20 @@ export const MultiSelect: React.FC = ({ const dropdownId = useId(); const inputId = useId(); + + useEffect(() => { + if (previousOpenState.current === isDropdownOpen) { + return; + } + + if (isDropdownOpen) { + onOpen?.(); + } else { + onClose?.(); + } + previousOpenState.current = isDropdownOpen; + }, [isDropdownOpen, onClose, onOpen]); + const selectedItemsLabel = selectedItems .filter((selectedItem) => items.some((item) => item.value === selectedItem.value), @@ -278,7 +336,7 @@ export const MultiSelect: React.FC = ({ className="multi-select" onToggleMenu={(isOpen) => { if (!isOpen) { - setFilter(""); + resetSearch(); } // Handle syncing the state when toggling the menu from within the // contextual menu component e.g. when clicking outside. @@ -300,10 +358,11 @@ export const MultiSelect: React.FC = ({ disabled={disabled} autoComplete="off" onChange={(value) => { - setFilter(value); + updateSearchValue(value); // reopen if dropdown has been closed via ESC setIsDropdownOpen(true); }} + onClear={resetSearch} onFocus={() => setIsDropdownOpen(true)} placeholder={placeholder ?? "Search"} required={required} @@ -368,6 +427,8 @@ export const MultiSelect: React.FC = ({ onSelectItem={onSelectItem} onDeselectItem={onDeselectItem} footer={footer} + emptyState={emptyState} + emptyMessage={emptyMessage} sortFn={isSortedAlphabetically ? sortAlphabetically : () => 0} hasSelectedItemsFirst={hasSelectedItemsFirst} /> From 9e9eabe454302d82766de54252a274358a708639 Mon Sep 17 00:00:00 2001 From: Urvashi Sharma Date: Wed, 29 Apr 2026 21:33:07 +0530 Subject: [PATCH 2/2] chore: simplify logic --- .../MultiSelect/MultiSelect.stories.tsx | 10 ++-- .../MultiSelect/MultiSelect.test.tsx | 34 ++++++------- src/components/MultiSelect/MultiSelect.tsx | 49 +++++++------------ 3 files changed, 35 insertions(+), 58 deletions(-) diff --git a/src/components/MultiSelect/MultiSelect.stories.tsx b/src/components/MultiSelect/MultiSelect.stories.tsx index f96023f78..1ad83f590 100644 --- a/src/components/MultiSelect/MultiSelect.stories.tsx +++ b/src/components/MultiSelect/MultiSelect.stories.tsx @@ -152,9 +152,8 @@ export const HelpText: Story = { }, }; -const FormikControlledSearchAndLifecycleTemplate = () => { +const FormikCallbacksAndEmptyStateTemplate = () => { const [selectedItems, setSelectedItems] = useState([]); - const [searchValue, setSearchValue] = useState(""); const [events, setEvents] = useState([]); const addEvent = (eventName: string) => { @@ -173,14 +172,11 @@ const FormikControlledSearchAndLifecycleTemplate = () => { items={groupedItems} selectedItems={selectedItems} onItemsUpdate={setSelectedItems} - searchValue={searchValue} onSearchChange={(value: string) => { - setSearchValue(value); addEvent(`onSearchChange("${value}")`); }} onOpen={() => addEvent("onOpen()")} onClose={() => addEvent("onClose()")} - onResetSearch={() => addEvent("onResetSearch()")} emptyMessage="No ingredients found" /> @@ -194,8 +190,8 @@ const FormikControlledSearchAndLifecycleTemplate = () => { ); }; -export const FormikControlledSearchAndLifecycle: Story = { - render: FormikControlledSearchAndLifecycleTemplate, +export const FormikCallbacksAndEmptyState: Story = { + render: FormikCallbacksAndEmptyStateTemplate, }; export const EmptyMessage: Story = { diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx index bb6299bb0..b7e2ea488 100644 --- a/src/components/MultiSelect/MultiSelect.test.tsx +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -97,33 +97,29 @@ it("can filter option list", async () => { await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(2)); }); -it("supports controlled search input", async () => { - const ControlledMultiSelect = () => { - const [searchValue, setSearchValue] = React.useState(""); - - return ( - - ); - }; - - render(); +it("tracks search changes via onSearchChange callback", async () => { + const onSearchChange = jest.fn(); + render( + , + ); await userEvent.click(screen.getByRole("combobox")); await userEvent.type(screen.getByRole("combobox"), "item"); expect(screen.getByRole("combobox")).toHaveValue("item"); - await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(2)); + expect(onSearchChange).toHaveBeenCalledWith("i"); + expect(onSearchChange).toHaveBeenCalledWith("it"); + expect(onSearchChange).toHaveBeenCalledWith("ite"); + expect(onSearchChange).toHaveBeenCalledWith("item"); }); -it("calls search lifecycle callbacks", async () => { +it("calls lifecycle callbacks", async () => { const onOpen = jest.fn(); const onClose = jest.fn(); - const onResetSearch = jest.fn(); render( { items={items} onOpen={onOpen} onClose={onClose} - onResetSearch={onResetSearch} />, ); @@ -142,7 +137,6 @@ it("calls search lifecycle callbacks", async () => { await userEvent.click(document.body); await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(onResetSearch).toHaveBeenCalledTimes(1); expect(screen.getByRole("combobox")).toHaveValue(""); }); diff --git a/src/components/MultiSelect/MultiSelect.tsx b/src/components/MultiSelect/MultiSelect.tsx index b4cd2c494..2c9148fb5 100644 --- a/src/components/MultiSelect/MultiSelect.tsx +++ b/src/components/MultiSelect/MultiSelect.tsx @@ -39,11 +39,9 @@ export type MultiSelectProps = { isSortedAlphabetically?: boolean; hasSelectedItemsFirst?: boolean; id?: string; - searchValue?: string; onSearchChange?: (value: string) => void; onOpen?: () => void; onClose?: () => void; - onResetSearch?: () => void; }; type ValueSet = Set; @@ -229,19 +227,24 @@ export const MultiSelect: React.FC = ({ isSortedAlphabetically = true, hasSelectedItemsFirst = true, id, - searchValue, onSearchChange, onOpen, onClose, - onResetSearch, help, helpClassName, }: MultiSelectProps) => { const buttonRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [internalSearchValue, setInternalSearchValue] = useState(""); - const previousOpenState = useRef(isDropdownOpen); - const filter = searchValue ?? internalSearchValue; + const [filter, setFilter] = useState(""); + + const handleSetDropdownOpen = (newState: boolean) => { + if (newState && !isDropdownOpen) { + onOpen?.(); + } else if (!newState && isDropdownOpen) { + onClose?.(); + } + setIsDropdownOpen(newState); + }; const [internalSelectedItems, setInternalSelectedItems] = useState< MultiSelectItem[] @@ -249,10 +252,8 @@ export const MultiSelect: React.FC = ({ const selectedItems = externalSelectedItems || internalSelectedItems; const helpId = useId(); - const updateSearchValue = (value: string) => { - if (searchValue === undefined) { - setInternalSearchValue(value); - } + const updateFilter = (value: string) => { + setFilter(value); onSearchChange?.(value); }; @@ -260,8 +261,7 @@ export const MultiSelect: React.FC = ({ if (!filter.length) { return; } - updateSearchValue(""); - onResetSearch?.(); + updateFilter(""); }; const updateItems = (newItems: MultiSelectItem[]) => { @@ -273,19 +273,6 @@ export const MultiSelect: React.FC = ({ const dropdownId = useId(); const inputId = useId(); - useEffect(() => { - if (previousOpenState.current === isDropdownOpen) { - return; - } - - if (isDropdownOpen) { - onOpen?.(); - } else { - onClose?.(); - } - previousOpenState.current = isDropdownOpen; - }, [isDropdownOpen, onClose, onOpen]); - const selectedItemsLabel = selectedItems .filter((selectedItem) => items.some((item) => item.value === selectedItem.value), @@ -341,7 +328,7 @@ export const MultiSelect: React.FC = ({ // Handle syncing the state when toggling the menu from within the // contextual menu component e.g. when clicking outside. if (isOpen !== isDropdownOpen) { - setIsDropdownOpen(isOpen); + handleSetDropdownOpen(isOpen); } }} position="left" @@ -358,12 +345,12 @@ export const MultiSelect: React.FC = ({ disabled={disabled} autoComplete="off" onChange={(value) => { - updateSearchValue(value); + updateFilter(value); // reopen if dropdown has been closed via ESC - setIsDropdownOpen(true); + handleSetDropdownOpen(true); }} onClear={resetSearch} - onFocus={() => setIsDropdownOpen(true)} + onFocus={() => handleSetDropdownOpen(true)} placeholder={placeholder ?? "Search"} required={required} type="text" @@ -379,7 +366,7 @@ export const MultiSelect: React.FC = ({ aria-expanded={isDropdownOpen} className="multi-select__select-button" onClick={() => { - setIsDropdownOpen(!isDropdownOpen); + handleSetDropdownOpen(!isDropdownOpen); }} onMouseDown={(event) => { // If the dropdown is open when this button is clicked the