Skip to content

Commit

Permalink
feat(simple-select): add listActionButton & onListAction
Browse files Browse the repository at this point in the history
  • Loading branch information
tomdavies73 committed Oct 28, 2024
1 parent e379a26 commit 334d335
Show file tree
Hide file tree
Showing 6 changed files with 397 additions and 0 deletions.
80 changes: 80 additions & 0 deletions src/components/select/simple-select/components.test-pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,60 @@ export const SimpleSelectMultipleColumnsComponent = (
);
};

export const SimpleSelectWithActionButtonComponent = () => {
const [value, setValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [optionList, setOptionList] = useState([
<Option text="Amber" value="amber" key="Amber" />,
<Option text="Black" value="black" key="Black" />,
<Option text="Blue" value="blue" key="Blue" />,
<Option text="Brown" value="brown" key="Brown" />,
<Option text="Green" value="green" key="Green" />,
<Option text="Amber" value="amber1" key="Amber1" />,
<Option text="Black" value="black1" key="Black1" />,
<Option text="Blue" value="blue1" key="Blue1" />,
<Option text="Brown" value="brown1" key="Brown1" />,
<Option text="Green" value="green1" key="Green1" />,
]);
function addNew() {
const counter = optionList.length.toString();
setOptionList((newOptionList) => [
...newOptionList,
<Option
text={`New${counter}`}
value={`val${counter}`}
key={`New${counter}`}
/>,
]);
setIsOpen(false);
setValue(`val${counter}`);
}
return (
<>
<SimpleSelect
label="color"
value={value}
onChange={(event) => setValue(event.target.value)}
listActionButton={
<Button iconType="add" iconPosition="after">
Add a New Element
</Button>
}
onListAction={() => setIsOpen(true)}
>
{optionList}
</SimpleSelect>
<Dialog
open={isOpen}
onCancel={() => setIsOpen(false)}
title="Dialog component triggered on action"
>
<Button onClick={addNew}>Add new</Button>
</Dialog>
</>
);
};

export const SimpleSelectCustomOptionChildrenComponent = (
props: Partial<SimpleSelectProps>
) => {
Expand Down Expand Up @@ -373,6 +427,32 @@ export const SimpleSelectWithLongWrappingTextComponent = () => (
</Box>
);

export const SimpleSelectListActionEventComponent = (
props: Partial<SimpleSelectProps>
) => {
const [value, setValue] = useState("");
return (
<SimpleSelect
label="color"
value={value}
labelInline
onChange={(event) => setValue(event.target.value)}
{...props}
listActionButton={
<Button iconType="add" iconPosition="after">
Add a New Element
</Button>
}
>
<Option text="Amber" value="1" />
<Option text="Black" value="2" />
<Option text="Blue" value="3" />
<Option text="Brown" value="4" />
<Option text="Green" value="5" />
</SimpleSelect>
);
};

export const WithVirtualScrolling = () => (
<SimpleSelect
name="Virtualised"
Expand Down
30 changes: 30 additions & 0 deletions src/components/select/simple-select/simple-select.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
} from "react";
import invariant from "invariant";

import { ButtonProps } from "../../button";
import { filterOutStyledSystemSpacingProps } from "../../../style/utils";
import StyledSelect from "../select.style";
import SelectTextbox, {
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface SimpleSelectProps
defaultValue?: string | Record<string, unknown>;
/** 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<ButtonProps>;
/** When true component will work in multi column mode.
* Children should consist of OptionRow components in this mode
*/
Expand All @@ -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.
Expand Down Expand Up @@ -126,7 +131,9 @@ export const SimpleSelect = React.forwardRef<
onKeyDown,
onBlur,
isLoading,
listActionButton,
listMaxHeight,
onListAction,
onListScrollBottom,
tableHeader,
multiColumn,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -369,6 +387,11 @@ export const SimpleSelect = React.forwardRef<
isMouseDownReported.current = true;
}

function handleOnListAction() {
setOpenState(false);
onListAction?.();
}

function handleTextboxBlur(event: React.FocusEvent<HTMLInputElement>) {
if (isMouseDownReported.current) {
return;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions src/components/select/simple-select/simple-select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ In this example the `maxWidth` prop is 100%.

<Canvas of={SimpleSelectStories.WithCustomMaxWidth} />

### 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`.
<Canvas of={SimpleSelectStories.WithActionButton} />

### 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.
Expand Down
97 changes: 97 additions & 0 deletions src/components/select/simple-select/simple-select.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
SimpleSelectWithLazyLoadingComponent,
SimpleSelectWithInfiniteScrollComponent,
SimpleSelectMultipleColumnsComponent,
SimpleSelectWithActionButtonComponent,
SimpleSelectObjectAsValueComponent,
SimpleSelectCustomOptionChildrenComponent,
SimpleSelectGroupComponent,
SimpleSelectWithLongWrappingTextComponent,
SimpleSelectEventsComponent,
SimpleSelectListActionEventComponent,
WithVirtualScrolling,
SimpleSelectNestedInDialog,
SelectWithOptionGroupHeader,
Expand Down Expand Up @@ -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(<SimpleSelectWithActionButtonComponent />);
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(<SimpleSelectWithActionButtonComponent />);
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(<SimpleSelectWithActionButtonComponent />);
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(<SimpleSelectWithActionButtonComponent />);
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"],
Expand Down Expand Up @@ -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(
<SimpleSelectListActionEventComponent onListAction={callback} />
);

await dropdownButton(page).click();
await page.locator('[data-component="button"]').click();
await expect(callbackCount).toBe(1);
});
});

test.describe("Check virtual scrolling", () => {
Expand Down Expand Up @@ -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(<SimpleSelectWithActionButtonComponent />);
await dropdownButton(page).click();
await checkAccessibility(page, undefined, "scrollable-region-focusable");
});

test("should pass accessibility tests with virtual scrolling", async ({
mount,
page,
Expand Down
50 changes: 50 additions & 0 deletions src/components/select/simple-select/simple-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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([
<Option text="Amber" value="amber" key="Amber" />,
<Option text="Black" value="black" key="Black" />,
<Option text="Blue" value="blue" key="Blue" />,
<Option text="Brown" value="brown" key="Brown" />,
<Option text="Green" value="green" key="Green" />,
]);
function addNew() {
const counter = optionList.length.toString();
setOptionList((previousOptionList) => [
...previousOptionList,
<Option
text={`New${counter}`}
value={`val${counter}`}
key={`New${counter}`}
/>,
]);
setIsOpen(false);
setValue(`val${counter}`);
}
return (
<Box height={300}>
<SimpleSelect
name="action"
id="action"
label="color"
value={value}
onChange={(event) => setValue(event.target.value)}
listActionButton
onListAction={() => setIsOpen(true)}
>
{optionList}
</SimpleSelect>
<Dialog
open={isOpen}
onCancel={() => setIsOpen(false)}
title="Dialog component triggered on action"
>
<Button onClick={addNew}>Add a New Element</Button>
</Dialog>
</Box>
);
};
WithActionButton.storyName = "With Action Button";
WithActionButton.parameters = { chromatic: { disableSnapshot: true } };

export const WithIsLoadingProp: Story = () => {
const preventLoading = useRef(false);
const [value, setValue] = useState("black");
Expand Down
Loading

0 comments on commit 334d335

Please sign in to comment.