diff --git a/src/components/select/simple-select/components.test-pw.tsx b/src/components/select/simple-select/components.test-pw.tsx
index 27eea052b0..c94e8252c2 100644
--- a/src/components/select/simple-select/components.test-pw.tsx
+++ b/src/components/select/simple-select/components.test-pw.tsx
@@ -266,6 +266,60 @@ export const SimpleSelectMultipleColumnsComponent = (
);
};
+export const SimpleSelectWithActionButtonComponent = () => {
+ const [value, setValue] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ const [optionList, setOptionList] = useState([
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ]);
+ function addNew() {
+ const counter = optionList.length.toString();
+ setOptionList((newOptionList) => [
+ ...newOptionList,
+ ,
+ ]);
+ setIsOpen(false);
+ setValue(`val${counter}`);
+ }
+ return (
+ <>
+ setValue(event.target.value)}
+ listActionButton={
+
+ }
+ onListAction={() => setIsOpen(true)}
+ >
+ {optionList}
+
+
+ >
+ );
+};
+
export const SimpleSelectCustomOptionChildrenComponent = (
props: Partial
) => {
@@ -373,6 +427,32 @@ export const SimpleSelectWithLongWrappingTextComponent = () => (
);
+export const SimpleSelectListActionEventComponent = (
+ props: Partial
+) => {
+ const [value, setValue] = useState("");
+ return (
+ setValue(event.target.value)}
+ {...props}
+ listActionButton={
+
+ }
+ >
+
+
+
+
+
+
+ );
+};
+
export const WithVirtualScrolling = () => (
;
/** If true the loader animation is displayed in the option list */
isLoading?: boolean;
+ /** True for default text button or a Button Component to be rendered */
+ listActionButton?: boolean | React.ReactElement;
/** When true component will work in multi column mode.
* Children should consist of OptionRow components in this mode
*/
@@ -64,6 +67,8 @@ export interface SimpleSelectProps
onListScrollBottom?: () => void;
/** A custom callback for when the dropdown menu opens */
onOpen?: () => void;
+ /** A callback for when the Action Button is triggered */
+ onListAction?: () => void;
/** If true the Component opens on focus */
openOnFocus?: boolean;
/** SelectList table header, should consist of multiple th elements.
@@ -126,7 +131,9 @@ export const SimpleSelect = React.forwardRef<
onKeyDown,
onBlur,
isLoading,
+ listActionButton,
listMaxHeight,
+ onListAction,
onListScrollBottom,
tableHeader,
multiColumn,
@@ -313,6 +320,17 @@ export const SimpleSelect = React.forwardRef<
}
}, [value, onChange]);
+ useEffect(() => {
+ const hasListActionButton = listActionButton !== undefined;
+ const onListActionMissingMessage =
+ "onListAction prop required when using listActionButton prop";
+
+ invariant(
+ !hasListActionButton || (hasListActionButton && onListAction),
+ onListActionMissingMessage
+ );
+ }, [listActionButton, onListAction]);
+
useEffect(() => {
const matchingOption = childOptions.find((child) =>
isExpectedOption(child, selectedValue)
@@ -369,6 +387,11 @@ export const SimpleSelect = React.forwardRef<
isMouseDownReported.current = true;
}
+ function handleOnListAction() {
+ setOpenState(false);
+ onListAction?.();
+ }
+
function handleTextboxBlur(event: React.FocusEvent) {
if (isMouseDownReported.current) {
return;
@@ -436,6 +459,11 @@ export const SimpleSelect = React.forwardRef<
} = optionData;
const isClickTriggered = selectionType === "click";
+ if (selectionType === "tab") {
+ setOpenState(false);
+ return;
+ }
+
updateValue(newValue, text, selectionConfirmed);
setActiveDescendantId(selectedOptionId);
@@ -518,7 +546,9 @@ export const SimpleSelect = React.forwardRef<
onMouseDown={handleListMouseDown}
onSelectListClose={onSelectListClose}
highlightedValue={selectedValue}
+ listActionButton={listActionButton}
listMaxHeight={listMaxHeight}
+ onListAction={handleOnListAction}
isLoading={isLoading}
onListScrollBottom={onListScrollBottom}
tableHeader={tableHeader}
diff --git a/src/components/select/simple-select/simple-select.mdx b/src/components/select/simple-select/simple-select.mdx
index 9c6d0f19a4..2e3e5d7fed 100644
--- a/src/components/select/simple-select/simple-select.mdx
+++ b/src/components/select/simple-select/simple-select.mdx
@@ -96,6 +96,11 @@ In this example the `maxWidth` prop is 100%.
+### With Action Button
+Setting the `listActionButton` prop to `true` renders a default `"Add New Item"` `Button`. However, a custom `Button` component can be passed as the `listActionButton` value via a node.
+We recommend this pattern for loading/adding new options to the `SimpleSelect`.
+
+
### With isLoading prop
When `isLoading` prop is passed, a loader will be appended at the end of the Select List. That functionality could be used to load the options asynchronously.
diff --git a/src/components/select/simple-select/simple-select.pw.tsx b/src/components/select/simple-select/simple-select.pw.tsx
index 0efb533986..1d9831b49a 100644
--- a/src/components/select/simple-select/simple-select.pw.tsx
+++ b/src/components/select/simple-select/simple-select.pw.tsx
@@ -6,11 +6,13 @@ import {
SimpleSelectWithLazyLoadingComponent,
SimpleSelectWithInfiniteScrollComponent,
SimpleSelectMultipleColumnsComponent,
+ SimpleSelectWithActionButtonComponent,
SimpleSelectObjectAsValueComponent,
SimpleSelectCustomOptionChildrenComponent,
SimpleSelectGroupComponent,
SimpleSelectWithLongWrappingTextComponent,
SimpleSelectEventsComponent,
+ SimpleSelectListActionEventComponent,
WithVirtualScrolling,
SimpleSelectNestedInDialog,
SelectWithOptionGroupHeader,
@@ -738,6 +740,75 @@ test.describe("SimpleSelect component", () => {
await expect(thirdColumnElement).toBeVisible();
});
+ test("should render list options with an action button and trigger Dialog on action", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dropdownButton(page).click();
+ await expect(selectListWrapper(page)).toBeVisible();
+ const addElementButtonElement = page.locator('[data-component="button"]');
+ await expect(addElementButtonElement).toBeVisible();
+ await expect(addElementButtonElement).toHaveText("Add a New Element");
+ const iconElement = page.locator('[type="add"]');
+ await expect(iconElement).toBeVisible();
+ await addElementButtonElement.click();
+ await expect(alertDialogPreview(page)).toBeVisible();
+ });
+ test("should render list options with an action button that is visible without scrolling and without affecting the list height", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dropdownButton(page).click();
+ await expect(selectListWrapper(page)).toBeVisible();
+ await expect(page.locator('[data-component="button"]')).toBeInViewport();
+ const selectListHeight = await selectListWrapper(
+ page
+ ).evaluate((wrapperElement) =>
+ parseInt(
+ window.getComputedStyle(wrapperElement).getPropertyValue("height")
+ )
+ );
+ await expect(selectListHeight).toBeGreaterThan(220);
+ await expect(selectListHeight).toBeLessThan(250);
+ });
+ test("when navigating with the keyboard, the selected option is not hidden behind an action button", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dropdownButton(page).click();
+ const inputElement = commonDataElementInputPreview(page);
+ for (let i = 0; i < 5; i++) {
+ // eslint-disable-next-line no-await-in-loop
+ await inputElement.focus();
+ // eslint-disable-next-line no-await-in-loop
+ await inputElement.press("ArrowDown");
+ }
+ await expect(selectOptionByText(page, "Green").nth(0)).toBeInViewport();
+ });
+ test("should add new list option from Add new Dialog", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const newOption = "New10";
+ await dropdownButton(page).click();
+ await expect(selectListWrapper(page)).toBeVisible();
+ const addElementButtonElement = page.locator('[data-component="button"]');
+ await expect(addElementButtonElement).toBeVisible();
+ await addElementButtonElement.click();
+ await expect(alertDialogPreview(page)).toBeVisible();
+ await page.waitForTimeout(250);
+ const addNewButtonElement = page.getByRole("button", {
+ name: "Add New",
+ });
+ await expect(addNewButtonElement).toBeVisible();
+ await addNewButtonElement.click();
+ await expect(selectText(page)).toHaveText(newOption);
+ });
+
([
[1, "favourite", "orange"],
[2, "money_bag", "black"],
@@ -1262,6 +1333,23 @@ test.describe("Check events for SimpleSelect component", () => {
await expect(callbackCount).toBe(1);
});
});
+
+ test("should call onListAction event when the Action Button is clicked", async ({
+ mount,
+ page,
+ }) => {
+ let callbackCount = 0;
+ const callback = () => {
+ callbackCount += 1;
+ };
+ await mount(
+
+ );
+
+ await dropdownButton(page).click();
+ await page.locator('[data-component="button"]').click();
+ await expect(callbackCount).toBe(1);
+ });
});
test.describe("Check virtual scrolling", () => {
@@ -1947,6 +2035,15 @@ test.describe("Accessibility tests for SimpleSelect component", () => {
await checkAccessibility(page, undefined, "scrollable-region-focusable");
});
+ test("should pass accessibility tests with an action button and trigger Dialog on action", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dropdownButton(page).click();
+ await checkAccessibility(page, undefined, "scrollable-region-focusable");
+ });
+
test("should pass accessibility tests with virtual scrolling", async ({
mount,
page,
diff --git a/src/components/select/simple-select/simple-select.stories.tsx b/src/components/select/simple-select/simple-select.stories.tsx
index ad6384258c..e6430d2697 100644
--- a/src/components/select/simple-select/simple-select.stories.tsx
+++ b/src/components/select/simple-select/simple-select.stories.tsx
@@ -13,6 +13,7 @@ import Icon from "../../icon";
import CarbonProvider from "../../carbon-provider";
import Box from "../../box";
import Typography from "../../typography";
+import Dialog from "../../dialog";
import generateStyledSystemProps from "../../../../.storybook/utils/styled-system-props";
import SimpleSelect, { SimpleSelectProps } from "./simple-select.component";
@@ -259,6 +260,55 @@ export const WithCustomMaxWidth: Story = () => {
};
WithCustomMaxWidth.storyName = "With Custom Max Width";
+export const WithActionButton: Story = () => {
+ const [value, setValue] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ const [optionList, setOptionList] = useState([
+ ,
+ ,
+ ,
+ ,
+ ,
+ ]);
+ function addNew() {
+ const counter = optionList.length.toString();
+ setOptionList((previousOptionList) => [
+ ...previousOptionList,
+ ,
+ ]);
+ setIsOpen(false);
+ setValue(`val${counter}`);
+ }
+ return (
+
+ setValue(event.target.value)}
+ listActionButton
+ onListAction={() => setIsOpen(true)}
+ >
+ {optionList}
+
+
+
+ );
+};
+WithActionButton.storyName = "With Action Button";
+WithActionButton.parameters = { chromatic: { disableSnapshot: true } };
+
export const WithIsLoadingProp: Story = () => {
const preventLoading = useRef(false);
const [value, setValue] = useState("black");
diff --git a/src/components/select/simple-select/simple-select.test.tsx b/src/components/select/simple-select/simple-select.test.tsx
index e21bc6dcb7..d6325d9dea 100644
--- a/src/components/select/simple-select/simple-select.test.tsx
+++ b/src/components/select/simple-select/simple-select.test.tsx
@@ -701,6 +701,141 @@ test("calls onKeyDown prop with details of the pressed key, when typing a charac
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "c" }));
});
+describe("when the `listActionButton` is passed", () => {
+ it("should render the element when the list is open", async () => {
+ render(
+ {}}
+ openOnFocus
+ label="filterable-select"
+ onChange={() => {}}
+ value=""
+ listActionButton={}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+
+ expect(
+ await screen.findByRole("button", { name: "mock button" })
+ ).toBeVisible();
+ });
+
+ it("should call the `onListAction` callback when the element is clicked", async () => {
+ const onListActionFn = jest.fn();
+ const user = userEvent.setup();
+ render(
+ {}}
+ value=""
+ listActionButton={}
+ onListAction={onListActionFn}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+ await user.click(
+ await screen.findByRole("button", { name: "mock button" })
+ );
+
+ expect(onListActionFn).toHaveBeenCalled();
+ });
+
+ it("should focus the element when the user presses the 'Tab' key when the input is focused", async () => {
+ const user = userEvent.setup();
+ render(
+ {}}
+ label="filterable-select"
+ onChange={() => {}}
+ value=""
+ listActionButton={}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+ await user.tab();
+
+ expect(
+ await screen.findByRole("button", { name: "mock button" })
+ ).toHaveFocus();
+ });
+
+ it("should call the `onListAction` callback when the element is focused and the user presses the 'Enter' key", async () => {
+ const user = userEvent.setup();
+ const onListActionFn = jest.fn();
+ render(
+ {}}
+ value=""
+ listActionButton={}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+ await user.tab();
+ await user.keyboard("{Enter}");
+
+ expect(onListActionFn).toHaveBeenCalled();
+ });
+
+ it("should call the `onListAction` callback when the element is focused and the user presses the 'Space' key", async () => {
+ const user = userEvent.setup();
+ const onListActionFn = jest.fn();
+ render(
+ {}}
+ value=""
+ listActionButton={}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+ await user.tab();
+ await user.keyboard(" ");
+
+ expect(onListActionFn).toHaveBeenCalled();
+ });
+
+ it("should call the `onSelect` callback when the user presses tab and the action button is focused", async () => {
+ const user = userEvent.setup();
+ const onSelectFn = jest.fn();
+
+ render(
+ {}}
+ label="filterable-select"
+ onChange={() => {}}
+ value=""
+ listActionButton={}
+ >
+
+
+ );
+ screen.getByRole("combobox").focus();
+ await user.tab();
+ await user.tab();
+
+ expect(onSelectFn).toHaveBeenCalled();
+ });
+});
+
test("calls onFocus prop when input is focused", async () => {
const onFocus = jest.fn();
const user = userEvent.setup();