Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/pretty-foxes-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/macaw-ui": minor
---

Combobox and DynamicCombobox now accepts new props that allow entering custom values
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Grammar: “Combobox and DynamicCombobox now accepts …” should use plural verb (“now accept …”).

Suggested change
Combobox and DynamicCombobox now accepts new props that allow entering custom values
Combobox and DynamicCombobox now accept new props that allow entering custom values

Copilot uses AI. Check for mistakes.
64 changes: 56 additions & 8 deletions src/components/Combobox/Common/useCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import {
useCombobox as useDownshiftCombobox,
UseComboboxGetInputPropsOptions,
} from "downshift";
import { FocusEvent, useState } from "react";
import { FocusEvent, useState, KeyboardEvent } from "react";

import {
Option,
SingleChangeHandler,
useHighlightedIndex,
} from "~/components/BaseSelect";

const CUSTOM_VALUE_SENTINEL = "__macaw_custom_value__";

export const isCustomValueOption = (item: Option): boolean =>
item.value === CUSTOM_VALUE_SENTINEL;

const getItemsFilter = <T extends Option>(
inputValue: string | undefined,
options: T[]
Expand All @@ -34,6 +39,8 @@ export const useCombobox = <T extends Option, V extends string | Option>({
onInputValueChange,
onFocus,
onBlur,
allowCustomValue,
onCustomValueSubmit,
}: {
selectedItem: T | null | undefined;
options: T[];
Expand All @@ -42,26 +49,49 @@ export const useCombobox = <T extends Option, V extends string | Option>({
onInputValueChange?: (value: string) => void;
onFocus?: (e: FocusEvent<HTMLInputElement, Element>) => void;
onBlur?: (e: FocusEvent<HTMLInputElement, Element>) => void;
allowCustomValue?: boolean;
onCustomValueSubmit?: (value: string) => void;
}) => {
const [inputValue, setInputValue] = useState<string>("");
const [active, setActive] = useState(false);
const typed = Boolean(selectedItem || active || inputValue);

const itemsToSelect = getItemsFilter<T>(inputValue, options);
const filteredItems = getItemsFilter<T>(inputValue, options);
const hasFilteredItems = filteredItems.length > 0;
const trimmedInputValue = inputValue.trim();

const canSubmitCustomValue =
!!allowCustomValue &&
!hasFilteredItems &&
trimmedInputValue.length > 0 &&
!!onCustomValueSubmit;

const customValueOption = canSubmitCustomValue
? ({ label: trimmedInputValue, value: CUSTOM_VALUE_SENTINEL } as T)
: null;

const itemsToSelect = customValueOption
? [...filteredItems, customValueOption]
: filteredItems;

const hasItemsToSelect = itemsToSelect.length > 0;

const { highlightedIndex, onHighlightedIndexChange } = useHighlightedIndex(
itemsToSelect,
selectedItem
);

const {
isOpen,
selectItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps: _getInputProps,
getItemProps,
} = useDownshiftCombobox({
items: itemsToSelect,
inputValue,
itemToString: (item) => item?.label ?? "",
selectedItem,
highlightedIndex,
Expand All @@ -82,19 +112,26 @@ export const useCombobox = <T extends Option, V extends string | Option>({
}
},
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
const selectedValue = isValuePassedAsString
? selectedItem.value
: selectedItem;
if (!selectedItem) return;

if (selectedItem.value === CUSTOM_VALUE_SENTINEL) {
onCustomValueSubmit?.(trimmedInputValue);
setInputValue("");
onChange?.(selectedValue as V);
return;
}

const selectedValue = isValuePassedAsString
? selectedItem.value
: selectedItem;
setInputValue("");
onChange?.(selectedValue as V);
},
});

return {
active,
itemsToSelect,
inputValue: trimmedInputValue,
typed,
isOpen,
getToggleButtonProps,
Expand All @@ -107,6 +144,7 @@ export const useCombobox = <T extends Option, V extends string | Option>({
_getInputProps<{
onFocus: (e: FocusEvent<HTMLInputElement>) => void;
onBlur: (e: FocusEvent<HTMLInputElement>) => void;
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
}>(
{
onFocus: (e) => {
Expand All @@ -117,12 +155,22 @@ export const useCombobox = <T extends Option, V extends string | Option>({
onBlur?.(e);
setActive(false);
},
onKeyDown: (e) => {
if (
e.key === "Enter" &&
canSubmitCustomValue &&
customValueOption &&
highlightedIndex === -1
) {
selectItem(customValueOption);
}
},
...options,
},
otherOptions
),
highlightedIndex,
getItemProps,
hasItemsToSelect: itemsToSelect.length > 0,
hasItemsToSelect,
};
};
44 changes: 44 additions & 0 deletions src/components/Combobox/Dynamic/DynamicCombobox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Meta } from "@storybook/react";
import { fn } from "@storybook/test";
import { useState } from "react";

import { DynamicCombobox } from "..";
Expand Down Expand Up @@ -177,3 +178,46 @@ export const NoOptions = () => {
</DynamicCombobox>
);
};

export const AllowCustomValue = () => {
const [options, setOptions] = useState<Option[]>([]);
const [value, setValue] = useState<Option | null>(null);
const [loading, setLoading] = useState(false);

const handleInputValueChange = async (criteria: string) => {
if (!criteria) {
setOptions([]);
return;
}

setLoading(true);
const response = await fetch(
`https://swapi.dev/api/people/?search=${criteria}`
);
const body = await response.json();

setOptions(
body.results.map((result: { name: string }) => ({
value: result.name,
label: result.name,
}))
);
setLoading(false);
Comment on lines +190 to +205
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

If criteria becomes empty, this returns early without resetting loading to false. After a previous search set loading true, the story can get stuck in a loading state. Consider calling setLoading(false) (and possibly clearing any in-flight request) before returning.

Suggested change
return;
}
setLoading(true);
const response = await fetch(
`https://swapi.dev/api/people/?search=${criteria}`
);
const body = await response.json();
setOptions(
body.results.map((result: { name: string }) => ({
value: result.name,
label: result.name,
}))
);
setLoading(false);
setLoading(false);
return;
}
setLoading(true);
try {
const response = await fetch(
`https://swapi.dev/api/people/?search=${criteria}`
);
const body = await response.json();
setOptions(
body.results.map((result: { name: string }) => ({
value: result.name,
label: result.name,
}))
);
} finally {
setLoading(false);
}

Copilot uses AI. Check for mistakes.
};

return (
<DynamicCombobox
__width="300px"
value={value}
label="Pick or create a character"
onChange={(value) => setValue(value)}
options={options}
loading={loading}
onInputValueChange={(inputValue) => {
handleInputValueChange(inputValue);
}}
allowCustomValue
onCustomValueSubmit={fn()}
/>
);
};
Comment on lines +182 to +223
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

PR description mentions an AllowCustomValueWithLocale story for the dynamic combobox as well, but this file only adds AllowCustomValue. Either add the locale variant story here (to exercise locale.addNewLabel) or update the PR description/test plan to match what’s included.

Copilot uses AI. Check for mistakes.
68 changes: 46 additions & 22 deletions src/components/Combobox/Dynamic/DynamicCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
SingleChangeHandler,
} from "../../BaseSelect";

import { ComboboxWrapper, useCombobox } from "../Common";
import { ComboboxWrapper } from "../Common";
import { isCustomValueOption, useCombobox } from "../Common/useCombobox";

export type DynamicComboboxProps<T> = PropsWithBox<
Omit<
Expand Down Expand Up @@ -54,8 +55,11 @@ export type DynamicComboboxProps<T> = PropsWithBox<
children?: ReactNode;
locale?: {
loadingText?: string;
addNewLabel?: string;
};
onScrollEnd?: () => void;
allowCustomValue?: boolean;
onCustomValueSubmit?: (value: string) => void;
}
> &
InputVariants;
Expand All @@ -81,6 +85,8 @@ const DynamicComboboxInner = <T extends Option>(
startAdornment,
endAdornment,
onScrollEnd,
allowCustomValue,
onCustomValueSubmit,
...props
}: DynamicComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement>
Expand All @@ -97,6 +103,7 @@ const DynamicComboboxInner = <T extends Option>(
getItemProps,
itemsToSelect,
hasItemsToSelect,
inputValue,
} = useCombobox({
selectedItem: value,
options,
Expand All @@ -105,6 +112,8 @@ const DynamicComboboxInner = <T extends Option>(
onInputValueChange,
onFocus,
onBlur,
allowCustomValue,
onCustomValueSubmit,
});

const { refs, floatingStyles } = useFloating<HTMLLabelElement>({
Expand Down Expand Up @@ -170,28 +179,43 @@ const DynamicComboboxInner = <T extends Option>(
{...getMenuProps({ ref: refs.floating })}
>
{isOpen &&
itemsToSelect?.map((item, index) => (
<List.Item
data-test-id="select-option"
key={`${id}-${item.value}-${index}-${highlightedIndex}`}
className={listItemStyle}
{...getItemProps({
item,
index,
disabled: item.disabled,
})}
active={highlightedIndex === index}
>
{item?.startAdornment}
<Text
color={item.disabled ? "defaultDisabled" : undefined}
size={getListTextSize(size)}
itemsToSelect?.map((item, index) =>
isCustomValueOption(item) ? (
<List.Item
data-test-id="combobox-custom-value"
key={`${id}-custom-value`}
className={listItemStyle}
{...getItemProps({ item, index })}
active={highlightedIndex === index}
cursor="pointer"
>
{item.label}
</Text>
{item?.endAdornment}
</List.Item>
))}
<Text size={getListTextSize(size)}>
{locale?.addNewLabel ?? "Add new"}: {inputValue}
</Text>
</List.Item>
) : (
<List.Item
data-test-id="select-option"
key={`${id}-${item.value}-${index}-${highlightedIndex}`}
className={listItemStyle}
{...getItemProps({
item,
index,
disabled: item.disabled,
})}
active={highlightedIndex === index}
>
{item?.startAdornment}
<Text
color={item.disabled ? "defaultDisabled" : undefined}
size={getListTextSize(size)}
>
{item.label}
</Text>
{item?.endAdornment}
</List.Item>
)
)}

{isOpen && !loading && !hasItemsToSelect && children}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

When hasCustomValueToSubmit is true, the empty-state children will still render because the condition is only !hasItemsToSelect. This can display a “No options” message alongside the “Add new …” row; consider also checking !hasCustomValueToSubmit (or render one or the other).

Suggested change
{isOpen && !loading && !hasItemsToSelect && children}
{isOpen &&
!loading &&
!hasItemsToSelect &&
!hasCustomValueToSubmit &&
children}

Copilot uses AI. Check for mistakes.

Expand Down
Loading