From 748a17b8185a2d9a2bd58843efbd4a10739c2e8d Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Fri, 27 Oct 2023 14:14:02 +0100 Subject: [PATCH 1/3] feat(select): add selectionConfirmed property to event emitted on change Adds a `CustomSelectChangeEvent` type which defines the shape of the new change event emitted when the input value changes, backwards compatibility is maintained so it is still possible to continue to use React's ChangeEvent. Within the new custom event there is a `selectionConfirmed` property that indicates when a user has pressed `Enter` or clicked on a given `Option`. fix #6330 --- .../filterable-select.cy.tsx | 75 +++++++++++ .../select/multi-select/multi-select.cy.tsx | 127 ++++++++++++++++++ .../select/simple-select/simple-select.cy.tsx | 74 ++++++++++ .../filterable-select-test.stories.tsx | 48 ++++++- .../filterable-select.component.tsx | 17 ++- .../filterable-select.spec.tsx | 20 ++- .../filterable-select.stories.mdx | 10 ++ .../filterable-select.stories.tsx | 47 ++++++- src/components/select/index.ts | 5 +- .../multi-select-test.stories.tsx | 48 ++++++- .../multi-select/multi-select.component.tsx | 16 ++- .../select/multi-select/multi-select.spec.tsx | 8 ++ .../select-list/select-list.component.tsx | 11 +- .../select/select-list/select-list.spec.tsx | 14 +- .../select-textbox.component.tsx | 7 +- src/components/select/simple-select/index.ts | 5 +- .../simple-select-test.stories.tsx | 46 ++++++- .../simple-select/simple-select.component.tsx | 19 ++- .../simple-select/simple-select.spec.tsx | 15 ++- .../simple-select/simple-select.stories.mdx | 11 ++ .../simple-select/simple-select.stories.tsx | 47 ++++++- 21 files changed, 635 insertions(+), 35 deletions(-) diff --git a/cypress/components/select/filterable-select/filterable-select.cy.tsx b/cypress/components/select/filterable-select/filterable-select.cy.tsx index deb9422c8d..60605349ce 100644 --- a/cypress/components/select/filterable-select/filterable-select.cy.tsx +++ b/cypress/components/select/filterable-select/filterable-select.cy.tsx @@ -1002,6 +1002,7 @@ context("Tests for FilterableSelect component", () => { selectOption(positionOfElement(position)).click(); cy.get("@onChange").should("have.been.calledWith", { target: { value: option }, + selectionConfirmed: true, }); }); @@ -1131,6 +1132,80 @@ context("Tests for FilterableSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + dropdownButton().click(); + selectListText("Five").click(); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + dropdownButton().click(); + selectListText("Seven").click(); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-7"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option after filtering", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + commonDataElementInputPreview().click().type("th"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + }); + }); + describe("Accessibility tests for FilterableSelect component", () => { it("should pass accessibilty tests for FilterableSelect", () => { CypressMountWithProviders(); diff --git a/cypress/components/select/multi-select/multi-select.cy.tsx b/cypress/components/select/multi-select/multi-select.cy.tsx index 1d02f47d98..b4c0d514ec 100644 --- a/cypress/components/select/multi-select/multi-select.cy.tsx +++ b/cypress/components/select/multi-select/multi-select.cy.tsx @@ -899,6 +899,7 @@ context("Tests for MultiSelect component", () => { selectOption(positionOfElement(position)).click(); cy.get("@onChange").should("have.been.calledWith", { target: { value: option }, + selectionConfirmed: true, }); }); @@ -1083,6 +1084,132 @@ context("Tests for MultiSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + selectListText("Five").click(); + selectListText("Seven").click(); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when the selected options are removed via Backspace key", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 2); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 1); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 0); + }); + + it("is set on the event when the selected options are removed via clicking close icon of Pills", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + selectListText("Five").click(); + selectListText("Seven").click(); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + pillCloseIcon().eq(2).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 2); + pillCloseIcon().eq(1).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 1); + pillCloseIcon().eq(0).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 0); + }); + }); + describe("Accessibility tests for MultiSelect component", () => { it("should pass accessibilty tests for MultiSelect", () => { CypressMountWithProviders(); diff --git a/cypress/components/select/simple-select/simple-select.cy.tsx b/cypress/components/select/simple-select/simple-select.cy.tsx index 15c3d319a6..3f5903db7c 100644 --- a/cypress/components/select/simple-select/simple-select.cy.tsx +++ b/cypress/components/select/simple-select/simple-select.cy.tsx @@ -991,6 +991,80 @@ context("Tests for SimpleSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + dropdownButton().click(); + selectListText("Five").click(); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + dropdownButton().click(); + selectListText("Seven").click(); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-7"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option after using alpha key", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().type("t", { force: true }); + cy.get('[data-element="confirmed-selection-2"]').should("not.exist"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-2"]').should("exist"); + }); + }); + describe("Accessibility tests for SimpleSelect component", () => { it("should pass accessibility tests for SimpleSelect", () => { CypressMountWithProviders(); diff --git a/src/components/select/filterable-select/filterable-select-test.stories.tsx b/src/components/select/filterable-select/filterable-select-test.stories.tsx index 4953773c01..c8c52d0235 100644 --- a/src/components/select/filterable-select/filterable-select-test.stories.tsx +++ b/src/components/select/filterable-select/filterable-select-test.stories.tsx @@ -1,6 +1,11 @@ import React, { useState } from "react"; import partialAction from "../../../../.storybook/utils/partial-action"; -import { FilterableSelect, Option, FilterableSelectProps } from ".."; +import { + FilterableSelect, + Option, + FilterableSelectProps, + CustomSelectChangeEvent, +} from ".."; import OptionRow from "../option-row/option-row.component"; import Dialog from "../../dialog"; import Button from "../../button"; @@ -8,6 +13,7 @@ import Button from "../../button"; export default { component: FilterableSelect, title: "Select/Filterable/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -641,3 +647,43 @@ export const FilterableSelectNestedInDialog = () => { ); }; + +export const SelectionConfirmed = () => { + const [value, setValue] = React.useState(""); + const [confirmedSelection, setConfirmedSelection] = useState(""); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue(event.target.value); + if (event.selectionConfirmed) { + setConfirmedSelection(event.target.value); + } + }; + return ( + <> + + + + {confirmedSelection ? ( + + {confirmedSelection} + + ) : null} + + ); +}; diff --git a/src/components/select/filterable-select/filterable-select.component.tsx b/src/components/select/filterable-select/filterable-select.component.tsx index e6ea9592e0..0afaee8539 100644 --- a/src/components/select/filterable-select/filterable-select.component.tsx +++ b/src/components/select/filterable-select/filterable-select.component.tsx @@ -17,6 +17,7 @@ import Logger from "../../../__internal__/utils/logger"; import useStableCallback from "../../../hooks/__internal__/useStableCallback"; import useFormSpacing from "../../../hooks/__internal__/useFormSpacing"; import useInputAccessibility from "../../../hooks/__internal__/useInputAccessibility/useInputAccessibility"; +import { CustomSelectChangeEvent } from "../simple-select"; let deprecateInputRefWarnTriggered = false; let deprecateUncontrolledWarnTriggered = false; @@ -169,24 +170,25 @@ export const FilterableSelect = React.forwardRef( } const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); const triggerChange = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { if (onChange) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } }, [onChange, createCustomEvent] @@ -213,7 +215,7 @@ export const FilterableSelect = React.forwardRef( if (!match || isFilterCleared) { setTextValue(newFilterText); - triggerChange(""); + triggerChange("", false); return ""; } @@ -224,7 +226,7 @@ export const FilterableSelect = React.forwardRef( return match.props.value; } - triggerChange(match.props.value); + triggerChange(match.props.value, false); if ( match.props.text @@ -456,6 +458,7 @@ export const FilterableSelect = React.forwardRef( text, value: newValue, selectionType, + selectionConfirmed, } = optionData; if (selectionType === "tab") { @@ -471,7 +474,7 @@ export const FilterableSelect = React.forwardRef( } setTextValue(text); - triggerChange(newValue); + triggerChange(newValue, selectionConfirmed); setActiveDescendantId(selectedOptionId); if (selectionType !== "navigationKey") { diff --git a/src/components/select/filterable-select/filterable-select.spec.tsx b/src/components/select/filterable-select/filterable-select.spec.tsx index de637a3375..c41e80ae85 100644 --- a/src/components/select/filterable-select/filterable-select.spec.tsx +++ b/src/components/select/filterable-select/filterable-select.spec.tsx @@ -579,23 +579,27 @@ describe("FilterableSelect", () => { value: "Foo", text: "Bar", selectionType: "navigationKey", + selectionConfirmed: false, }; const clickOptionObject = { value: "Foo", text: "Bar", selectionType: "click", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: "Foo", }, }; const expectedDeleteEventObject = { + selectionConfirmed: false, target: { ...textboxProps, value: "", @@ -880,6 +884,7 @@ describe("FilterableSelect", () => { value: "opt3", text: "black", selectionType: "click", + selectionConfirmed: true, }; beforeEach(() => { @@ -900,7 +905,10 @@ describe("FilterableSelect", () => { act(() => { wrapper.find(SelectList).prop("onSelect")(clickOptionObject); }); - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: true, + ...expectedObject, + }); }); }); @@ -912,7 +920,10 @@ describe("FilterableSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); describe("and an an empty value has been passed", () => { @@ -931,7 +942,10 @@ describe("FilterableSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); it("then the Textbox visible value should be changed to that character", () => { diff --git a/src/components/select/filterable-select/filterable-select.stories.mdx b/src/components/select/filterable-select/filterable-select.stories.mdx index d19e0688ae..63c4047edd 100644 --- a/src/components/select/filterable-select/filterable-select.stories.mdx +++ b/src/components/select/filterable-select/filterable-select.stories.mdx @@ -167,6 +167,16 @@ be customised if desired using the `virtualScrollOverscan` prop. Higher values w +### Selection confirmed + +A change event is emitted each time an option is navigated via keyboard as it sets the value of the +Select input. For those that need to trigger further actions when the user makes a selection, there is +a `selectionConfirmed` property on the emitted event when the enter key is pressed or an option is clicked. + + + + + ## Props ### Filterable Select diff --git a/src/components/select/filterable-select/filterable-select.stories.tsx b/src/components/select/filterable-select/filterable-select.stories.tsx index fa7d84b8c8..2156c13099 100644 --- a/src/components/select/filterable-select/filterable-select.stories.tsx +++ b/src/components/select/filterable-select/filterable-select.stories.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef } from "react"; -import { FilterableSelect, Option, OptionRow } from ".."; +import { + CustomSelectChangeEvent, + FilterableSelect, + Option, + OptionRow, +} from ".."; import Button from "../../button"; import Dialog from "../../dialog"; import CarbonProvider from "../../carbon-provider"; import Box from "../../box"; +import Icon from "../../icon"; +import Typography from "../../typography"; export const Default = () => ( @@ -708,3 +715,41 @@ export const Virtualised = () => { ); }; + +export const SelectionConfirmedStory = () => { + const [selectionConfirmed, setSelectionConfirmed] = useState(false); + return ( + + + Selection Confirmed:{" "} + {selectionConfirmed ? ( + + ) : ( + + )} + + { + setSelectionConfirmed(!!ev.selectionConfirmed); + }} + name="selection confirmed" + id="selection confirmed" + label="color" + > + + + ); +}; + +SelectionConfirmedStory.parameters = { chromatic: { disableSnapshot: true } }; diff --git a/src/components/select/index.ts b/src/components/select/index.ts index e19fece48e..b1203823ce 100644 --- a/src/components/select/index.ts +++ b/src/components/select/index.ts @@ -5,7 +5,10 @@ export type { OptionRowProps } from "./option-row"; export { default as OptionGroupHeader } from "./option-group-header"; export type { OptionGroupHeaderProps } from "./option-group-header"; export { default as Select } from "./simple-select"; -export type { SimpleSelectProps } from "./simple-select"; +export type { + SimpleSelectProps, + CustomSelectChangeEvent, +} from "./simple-select"; export { default as FilterableSelect } from "./filterable-select"; export type { FilterableSelectProps } from "./filterable-select"; export { default as MultiSelect } from "./multi-select"; diff --git a/src/components/select/multi-select/multi-select-test.stories.tsx b/src/components/select/multi-select/multi-select-test.stories.tsx index 4eefd7d711..152953756d 100644 --- a/src/components/select/multi-select/multi-select-test.stories.tsx +++ b/src/components/select/multi-select/multi-select-test.stories.tsx @@ -1,5 +1,10 @@ import React, { useState } from "react"; -import { MultiSelect, Option, MultiSelectProps } from ".."; +import { + MultiSelect, + Option, + MultiSelectProps, + CustomSelectChangeEvent, +} from ".."; import partialAction from "../../../../.storybook/utils/partial-action"; import OptionRow from "../option-row/option-row.component"; import Button from "../../button/button.component"; @@ -10,6 +15,7 @@ import CarbonProvider from "../../carbon-provider/carbon-provider.component"; export default { component: MultiSelect, title: "Select/MultiSelect/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -589,3 +595,43 @@ export const MultiSelectErrorOnChangeNewValidation = () => { ); }; + +export const SelectionConfirmed = () => { + const [value, setValue] = useState([]); + const [confirmedSelections, setConfirmedSelections] = useState([]); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue((event.target.value as unknown) as string[]); + if (event.selectionConfirmed) { + setConfirmedSelections((event.target.value as unknown) as string[]); + } + }; + return ( + <> + + + +
+ {confirmedSelections.map((cs) => ( + {cs} + ))} +
+ + ); +}; diff --git a/src/components/select/multi-select/multi-select.component.tsx b/src/components/select/multi-select/multi-select.component.tsx index 90d967a107..6690d3bc28 100644 --- a/src/components/select/multi-select/multi-select.component.tsx +++ b/src/components/select/multi-select/multi-select.component.tsx @@ -28,6 +28,7 @@ import useFormSpacing from "../../../hooks/__internal__/useFormSpacing"; import useInputAccessibility from "../../../hooks/__internal__/useInputAccessibility/useInputAccessibility"; import { OptionProps } from "../option"; import { OptionRowProps } from "../option-row"; +import { CustomSelectChangeEvent } from "../simple-select"; let deprecateInputRefWarnTriggered = false; let deprecateUncontrolledWarnTriggered = false; @@ -191,16 +192,17 @@ export const MultiSelect = React.forwardRef( }, [onOpen]); const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); @@ -213,14 +215,15 @@ export const MultiSelect = React.forwardRef( ( updateFunction: ( previousValue: string[] | Record[] - ) => string[] | Record[] + ) => string[] | Record[], + selectionConfirmed ) => { const newValue = updateFunction( actualValue as string[] | Record[] ); // only call onChange if an option has been selected or deselected if (onChange && newValue.length !== actualValue?.length) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } // no need to update selectedValue if the component is controlled: onChange should take care of updating the value @@ -270,7 +273,7 @@ export const MultiSelect = React.forwardRef( newValue.splice(index, 1); return newValue; - }); + }, true); }, [updateValue] ); @@ -534,6 +537,7 @@ export const MultiSelect = React.forwardRef( value: newValue, selectionType, id: selectedOptionId, + selectionConfirmed, } = optionData; if (selectionType === "navigationKey") { @@ -561,7 +565,7 @@ export const MultiSelect = React.forwardRef( } return [...previousValue, newValue]; - }); + }, selectionConfirmed); }, [textboxRef, actualValue, updateValue] ); diff --git a/src/components/select/multi-select/multi-select.spec.tsx b/src/components/select/multi-select/multi-select.spec.tsx index 7c1d61067b..0122877ab4 100644 --- a/src/components/select/multi-select/multi-select.spec.tsx +++ b/src/components/select/multi-select/multi-select.spec.tsx @@ -484,6 +484,7 @@ describe("MultiSelect", () => { value: "opt3", text: "blue", selectionType: "enter", + selectionConfirmed: true, }; const changeEventObject = { target: { value: "b" } }; @@ -749,17 +750,20 @@ describe("MultiSelect", () => { value: "opt1", text: "red", selectionType: "enter", + selectionConfirmed: true, }; const mockNavigationKeyOptionObject = { value: "opt1", text: "red", selectionType: "navigationKey", + selectionConfirmed: false, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: ["opt1"], @@ -958,6 +962,7 @@ describe("MultiSelect", () => { describe("when the component is controlled", () => { const expectedObject = { + selectionConfirmed: true, target: { id: "testSelect", name: "testSelect", @@ -969,6 +974,7 @@ describe("MultiSelect", () => { value: "opt2", text: "black", selectionType: "click", + selectionConfirmed: true, }; describe("and an option is selected", () => { @@ -1069,12 +1075,14 @@ describe("MultiSelect", () => { value: "opt1", text: "red", selectionType: "enter", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: ["opt1"], diff --git a/src/components/select/select-list/select-list.component.tsx b/src/components/select/select-list/select-list.component.tsx index 1d8c86b1af..0ea42b59bb 100644 --- a/src/components/select/select-list/select-list.component.tsx +++ b/src/components/select/select-list/select-list.component.tsx @@ -48,6 +48,7 @@ export interface SelectListProps { value?: string | Record; id?: string; selectionType: string; + selectionConfirmed: boolean; }) => void; /** A callback for when the list should be closed */ onSelectListClose: () => void; @@ -185,7 +186,11 @@ const SelectList = React.forwardRef( const handleSelect = useCallback( (optionData) => { - onSelect({ ...optionData, selectionType: "click" }); + onSelect({ + ...optionData, + selectionType: "click", + selectionConfirmed: true, + }); }, [onSelect] ); @@ -329,6 +334,7 @@ const SelectList = React.forwardRef( id: childIds ? childIds[nextIndex] : /* istanbul ignore next */ undefined, + selectionConfirmed: false, }); }, [ @@ -345,7 +351,7 @@ const SelectList = React.forwardRef( const handleActionButtonTab = useCallback( (event, isActionButtonFocused) => { if (isActionButtonFocused) { - onSelect({ selectionType: "tab" }); + onSelect({ selectionType: "tab", selectionConfirmed: false }); } else { event.preventDefault(); listActionButtonRef.current?.focus(); @@ -393,6 +399,7 @@ const SelectList = React.forwardRef( text, value, selectionType: "enterKey", + selectionConfirmed: true, }); } else if (isNavigationKey(key)) { focusOnAnchor(); diff --git a/src/components/select/select-list/select-list.spec.tsx b/src/components/select/select-list/select-list.spec.tsx index 58409ca289..1591f93686 100644 --- a/src/components/select/select-list/select-list.spec.tsx +++ b/src/components/select/select-list/select-list.spec.tsx @@ -346,6 +346,7 @@ describe("SelectList", () => { selectionType: "enterKey", text: "blue", value: "opt3", + selectionConfirmed: true, }); }); }); @@ -363,6 +364,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); @@ -380,6 +382,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); @@ -410,6 +413,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -427,6 +431,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -443,6 +448,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); @@ -458,6 +464,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -498,6 +505,7 @@ describe("SelectList", () => { selectionType: "click", text: "red", value: "opt1", + selectionConfirmed: true, }); }); }); @@ -771,7 +779,10 @@ describe("SelectList", () => { const testContainer = document.createElement("div"); const onFocusFn = jest.fn(); const onSelectFn = jest.fn(); - const expectedSelectValue = { selectionType: "tab" }; + const expectedSelectValue = { + selectionType: "tab", + selectionConfirmed: false, + }; document.body.appendChild(testContainer); @@ -915,6 +926,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); diff --git a/src/components/select/select-textbox/select-textbox.component.tsx b/src/components/select/select-textbox/select-textbox.component.tsx index 8f86fa400c..dd6cc5e2c9 100644 --- a/src/components/select/select-textbox/select-textbox.component.tsx +++ b/src/components/select/select-textbox/select-textbox.component.tsx @@ -6,6 +6,7 @@ import Textbox, { CommonTextboxProps } from "../../textbox"; import SelectText from "../__internal__/select-text"; import useLocale from "../../../hooks/__internal__/useLocale"; import { ValidationProps } from "../../../__internal__/validations"; +import { CustomSelectChangeEvent } from "../simple-select/simple-select.component"; const floatingMiddleware = [ offset(({ rects }) => ({ @@ -21,7 +22,7 @@ const floatingMiddleware = [ export interface FormInputPropTypes extends ValidationProps, - Omit { + Omit { /** Breakpoint for adaptive label (inline labels change to top aligned). Enables the adaptive behaviour when set */ adaptiveLabelBreakpoint?: number; /** Prop to specify the aria-label attribute of the component input */ @@ -49,7 +50,9 @@ export interface FormInputPropTypes /** Specify a callback triggered on blur */ onBlur?: (ev: React.FocusEvent) => void; /** Specify a callback triggered on change */ - onChange?: (ev: React.ChangeEvent) => void; + onChange?: ( + ev: CustomSelectChangeEvent | React.ChangeEvent + ) => void; /** Specify a callback triggered on click */ onClick?: (ev: React.MouseEvent) => void; /** Specify a callback triggered on focus */ diff --git a/src/components/select/simple-select/index.ts b/src/components/select/simple-select/index.ts index ab19d3d035..97a1553a58 100644 --- a/src/components/select/simple-select/index.ts +++ b/src/components/select/simple-select/index.ts @@ -1,2 +1,5 @@ export { default } from "./simple-select.component"; -export type { SimpleSelectProps } from "./simple-select.component"; +export type { + SimpleSelectProps, + CustomSelectChangeEvent, +} from "./simple-select.component"; diff --git a/src/components/select/simple-select/simple-select-test.stories.tsx b/src/components/select/simple-select/simple-select-test.stories.tsx index 5bc5935fcc..f2d15f10e2 100644 --- a/src/components/select/simple-select/simple-select-test.stories.tsx +++ b/src/components/select/simple-select/simple-select-test.stories.tsx @@ -1,7 +1,10 @@ import React, { useState } from "react"; import Typography from "../../../components/typography"; import Content from "../../../components/content"; -import { Select as SimpleSelect } from "../../../../src/components/select"; +import { + CustomSelectChangeEvent, + Select as SimpleSelect, +} from "../../../../src/components/select"; import OptionRow from "../option-row/option-row.component"; import OptionGroupHeader from "../option-group-header/option-group-header.component"; import Box from "../../box"; @@ -12,6 +15,7 @@ import { Select, Option, SimpleSelectProps } from ".."; export default { component: Select, title: "Select/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -702,3 +706,43 @@ SelectWithOptionGroupHeader.args = { listPlacement: undefined, flipEnabled: true, }; + +export const SelectionConfirmed = () => { + const [value, setValue] = React.useState(""); + const [confirmedSelection, setConfirmedSelection] = useState(""); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue(event.target.value); + if (event.selectionConfirmed) { + setConfirmedSelection(event.target.value); + } + }; + return ( + <> + + + {confirmedSelection ? ( + + {confirmedSelection} + + ) : null} + + ); +}; diff --git a/src/components/select/simple-select/simple-select.component.tsx b/src/components/select/simple-select/simple-select.component.tsx index 0df95e1eb7..634c5b2853 100644 --- a/src/components/select/simple-select/simple-select.component.tsx +++ b/src/components/select/simple-select/simple-select.component.tsx @@ -25,6 +25,11 @@ let deprecateUncontrolledWarnTriggered = false; type TimerId = ReturnType; +export interface CustomSelectChangeEvent + extends React.ChangeEvent { + selectionConfirmed?: boolean; +} + export interface SimpleSelectProps extends Omit { /** Prop to specify the aria-label attribute of the component input */ @@ -164,16 +169,17 @@ export const SimpleSelect = React.forwardRef( ]) as React.ReactElement[]; const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed = false) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); @@ -406,7 +412,8 @@ export const SimpleSelect = React.forwardRef( function updateValue( newValue?: string | Record, - text?: string + text?: string, + selectionConfirmed?: boolean ) { if (!isControlled.current) { setSelectedValue(newValue); @@ -414,7 +421,7 @@ export const SimpleSelect = React.forwardRef( } if (onChange) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } } @@ -423,16 +430,18 @@ export const SimpleSelect = React.forwardRef( value?: string | Record; id?: string; selectionType: string; + selectionConfirmed?: boolean; }) { const { text, value: newValue, selectionType, id: selectedOptionId, + selectionConfirmed, } = optionData; const isClickTriggered = selectionType === "click"; - updateValue(newValue, text); + updateValue(newValue, text, selectionConfirmed); setActiveDescendantId(selectedOptionId); if (selectionType !== "navigationKey") { diff --git a/src/components/select/simple-select/simple-select.spec.tsx b/src/components/select/simple-select/simple-select.spec.tsx index 8101b7974c..999965f1a2 100644 --- a/src/components/select/simple-select/simple-select.spec.tsx +++ b/src/components/select/simple-select/simple-select.spec.tsx @@ -700,6 +700,7 @@ describe("SimpleSelect", () => { id: "testId", }; const mockEventObject = { + selectionConfirmed: false, target: { ...textboxProps, value: "opt3", @@ -720,17 +721,20 @@ describe("SimpleSelect", () => { value: "opt2", text: "green", selectionType: "navigationKey", + selectionConfirmed: false, }; const clickOptionObject = { value: "opt2", text: "green", selectionType: "click", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: "opt2", @@ -894,6 +898,7 @@ describe("SimpleSelect", () => { value: "opt3", text: "black", selectionType: "click", + selectionConfirmed: true, }; beforeEach(() => { @@ -910,7 +915,10 @@ describe("SimpleSelect", () => { act(() => { wrapper.find(SelectList).prop("onSelect")(clickOptionObject); }); - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: true, + ...expectedObject, + }); }); }); @@ -925,7 +933,10 @@ describe("SimpleSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); }); diff --git a/src/components/select/simple-select/simple-select.stories.mdx b/src/components/select/simple-select/simple-select.stories.mdx index d7bed0946e..8787c6d9ce 100644 --- a/src/components/select/simple-select/simple-select.stories.mdx +++ b/src/components/select/simple-select/simple-select.stories.mdx @@ -192,6 +192,17 @@ be customised if desired using the `virtualScrollOverscan` prop. Higher values w +### Selection confirmed + +A change event is emitted each time an option is navigated via keyboard as it sets the value of the +Select input. For those that need to trigger further actions when the user makes a selection, there is +a `selectionConfirmed` property on the emitted event when the enter key is pressed or an option is clicked. + + + + + + ## Props ### Select diff --git a/src/components/select/simple-select/simple-select.stories.tsx b/src/components/select/simple-select/simple-select.stories.tsx index fc8a2486bd..ccd6dbf1a0 100644 --- a/src/components/select/simple-select/simple-select.stories.tsx +++ b/src/components/select/simple-select/simple-select.stories.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef } from "react"; -import { Select, Option, OptionRow, OptionGroupHeader } from ".."; +import { + Select, + Option, + OptionRow, + OptionGroupHeader, + CustomSelectChangeEvent, +} from ".."; import Button from "../../button"; import Icon from "../../icon"; import CarbonProvider from "../../carbon-provider"; import Box from "../../box"; +import Typography from "../../typography"; export const Default = () => ( { + setSelectionConfirmed(!!ev.selectionConfirmed); + }} + name="selection confirmed" + id="selection confirmed" + label="color" + > +