✨(react) rework the behavior of the Select component
We decided to change a bit the behavior of the Select: - Do not trigger onChange on first render if value is defined - Show all options on searchable select menu opening even if there is an existing value. - Clear the input field if no choice are selected - Clear the added text to the input field when a value is already selected
This commit is contained in:
5
.changeset/wicked-games-tie.md
Normal file
5
.changeset/wicked-games-tie.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
rework the behavior of the Select component
|
||||||
@@ -52,6 +52,25 @@ export const SelectMonoSearchable = (props: SubProps) => {
|
|||||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||||
}, [props.value, props.options, props.downshiftProps]);
|
}, [props.value, props.options, props.downshiftProps]);
|
||||||
|
|
||||||
|
// Even there is already a value selected, when opening the combobox menu we want to display all available choices.
|
||||||
|
useEffect(() => {
|
||||||
|
if (downshiftReturn.isOpen) {
|
||||||
|
setOptionsToDisplay(props.options);
|
||||||
|
}
|
||||||
|
}, [downshiftReturn.isOpen]);
|
||||||
|
|
||||||
|
const onInputBlur = () => {
|
||||||
|
setHasInputFocused(false);
|
||||||
|
if (downshiftReturn.selectedItem) {
|
||||||
|
// Here the goal is to make sure that when the input in blurred then the input value
|
||||||
|
// has exactly the selectedItem label. Which is not the case by default.
|
||||||
|
downshiftReturn.selectItem(downshiftReturn.selectedItem);
|
||||||
|
} else {
|
||||||
|
// We want the input to be empty when no item is selected.
|
||||||
|
downshiftReturn.setInputValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const inputProps = downshiftReturn.getInputProps({
|
const inputProps = downshiftReturn.getInputProps({
|
||||||
ref: inputRef,
|
ref: inputRef,
|
||||||
disabled: props.disabled,
|
disabled: props.disabled,
|
||||||
@@ -82,7 +101,7 @@ export const SelectMonoSearchable = (props: SubProps) => {
|
|||||||
setHasInputFocused(true);
|
setHasInputFocused(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setHasInputFocused(false);
|
onInputBlur();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SelectMonoAux>
|
</SelectMonoAux>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { CunninghamProvider } from ":/components/Provider";
|
|||||||
import {
|
import {
|
||||||
expectMenuToBeClosed,
|
expectMenuToBeClosed,
|
||||||
expectMenuToBeOpen,
|
expectMenuToBeOpen,
|
||||||
|
expectNoOptions,
|
||||||
expectOptions,
|
expectOptions,
|
||||||
expectOptionToBeDisabled,
|
expectOptionToBeDisabled,
|
||||||
expectOptionToBeSelected,
|
expectOptionToBeSelected,
|
||||||
expectOptionToBeUnselected,
|
expectOptionToBeUnselected,
|
||||||
} from ":/components/Forms/Select/test-utils";
|
} from ":/components/Forms/Select/test-utils";
|
||||||
|
import { Input } from ":/components/Forms/Input";
|
||||||
|
|
||||||
describe("<Select/>", () => {
|
describe("<Select/>", () => {
|
||||||
describe("Searchable", () => {
|
describe("Searchable", () => {
|
||||||
@@ -139,6 +141,141 @@ describe("<Select/>", () => {
|
|||||||
|
|
||||||
expect(input).toHaveValue("Paris");
|
expect(input).toHaveValue("Paris");
|
||||||
});
|
});
|
||||||
|
it("clears the text input if no item is selected", async () => {
|
||||||
|
const Wrapper = () => {
|
||||||
|
const [value, setValue] = useState<string | number | undefined>();
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<div>Value = {value}|</div>
|
||||||
|
<Select
|
||||||
|
label="City"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Paris",
|
||||||
|
value: "paris",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Panama",
|
||||||
|
value: "panama",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "London",
|
||||||
|
value: "london",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New York",
|
||||||
|
value: "new_york",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tokyo",
|
||||||
|
value: "tokyo",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
searchable={true}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value as any)}
|
||||||
|
/>
|
||||||
|
<Input name="Something else" />
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(<Wrapper />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const input = screen.getByRole("combobox", {
|
||||||
|
name: "City",
|
||||||
|
});
|
||||||
|
screen.getByText("Value = |");
|
||||||
|
// It returns the input.
|
||||||
|
expect(input.tagName).toEqual("INPUT");
|
||||||
|
|
||||||
|
const menu: HTMLDivElement = screen.getByRole("listbox", {
|
||||||
|
name: "City",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectMenuToBeClosed(menu);
|
||||||
|
|
||||||
|
// Click on the input.
|
||||||
|
await user.click(input);
|
||||||
|
expectMenuToBeOpen(menu);
|
||||||
|
expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]);
|
||||||
|
|
||||||
|
// Verify that filtering works.
|
||||||
|
await user.type(input, "Pa");
|
||||||
|
expectMenuToBeOpen(menu);
|
||||||
|
expectOptions(["Paris", "Panama"]);
|
||||||
|
|
||||||
|
await user.type(input, "rr");
|
||||||
|
expectNoOptions();
|
||||||
|
|
||||||
|
expect(input).toHaveValue("Parr");
|
||||||
|
|
||||||
|
// This is a way to blur the combobox.
|
||||||
|
await user.click(screen.getByRole("textbox"));
|
||||||
|
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
screen.getByText("Value = |");
|
||||||
|
});
|
||||||
|
it("clears the added text to the existing value input on blur if no other item is selected", async () => {
|
||||||
|
const Wrapper = () => {
|
||||||
|
const [value, setValue] = useState<string | number | undefined>(
|
||||||
|
"london",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<div>Value = {value}|</div>
|
||||||
|
<Select
|
||||||
|
label="City"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Paris",
|
||||||
|
value: "paris",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Panama",
|
||||||
|
value: "panama",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "London",
|
||||||
|
value: "london",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New York",
|
||||||
|
value: "new_york",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tokyo",
|
||||||
|
value: "tokyo",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
searchable={true}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value as any)}
|
||||||
|
/>
|
||||||
|
<Input name="Something else" />
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(<Wrapper />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const input = screen.getByRole("combobox", {
|
||||||
|
name: "City",
|
||||||
|
});
|
||||||
|
screen.getByText("Value = london|");
|
||||||
|
// It returns the input.
|
||||||
|
expect(input.tagName).toEqual("INPUT");
|
||||||
|
|
||||||
|
expect(input).toHaveValue("London");
|
||||||
|
await user.type(input, "rr");
|
||||||
|
expect(input).toHaveValue("Londonrr");
|
||||||
|
|
||||||
|
// This is a way to blur the combobox.
|
||||||
|
await user.click(screen.getByRole("textbox"));
|
||||||
|
|
||||||
|
expect(input).toHaveValue("London");
|
||||||
|
screen.getByText("Value = london|");
|
||||||
|
});
|
||||||
it("clears value", async () => {
|
it("clears value", async () => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
@@ -290,10 +427,12 @@ describe("<Select/>", () => {
|
|||||||
const [value, setValue] = useState<string | number | undefined>(
|
const [value, setValue] = useState<string | number | undefined>(
|
||||||
"london",
|
"london",
|
||||||
);
|
);
|
||||||
|
const [onChangeCounts, setOnChangeCounts] = useState(0);
|
||||||
return (
|
return (
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<div>
|
<div>
|
||||||
<div>Value = {value}|</div>
|
<div>Value = {value}|</div>
|
||||||
|
<div>onChangeCounts = {onChangeCounts}|</div>
|
||||||
<Button onClick={() => setValue(undefined)}>Clear</Button>
|
<Button onClick={() => setValue(undefined)}>Clear</Button>
|
||||||
<Button onClick={() => setValue("paris")}>Set Paris</Button>
|
<Button onClick={() => setValue("paris")}>Set Paris</Button>
|
||||||
<Select
|
<Select
|
||||||
@@ -317,7 +456,10 @@ describe("<Select/>", () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value as string)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value as string);
|
||||||
|
setOnChangeCounts(onChangeCounts + 1);
|
||||||
|
}}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,6 +474,7 @@ describe("<Select/>", () => {
|
|||||||
|
|
||||||
// Make sure value is selected.
|
// Make sure value is selected.
|
||||||
screen.getByText("Value = london|");
|
screen.getByText("Value = london|");
|
||||||
|
screen.getByText("onChangeCounts = 0|");
|
||||||
|
|
||||||
// Change value.
|
// Change value.
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -343,14 +486,16 @@ describe("<Select/>", () => {
|
|||||||
});
|
});
|
||||||
expectOptionToBeSelected(option);
|
expectOptionToBeSelected(option);
|
||||||
|
|
||||||
// List should show only selected value.
|
// List should display all available options when re-opening.
|
||||||
expectOptions(["London"]);
|
expectOptions(["Paris", "London", "New York", "Tokyo"]);
|
||||||
|
|
||||||
// Clear value.
|
// Clear value.
|
||||||
const button = screen.getByRole("button", {
|
const button = screen.getByRole("button", {
|
||||||
name: "Clear",
|
name: "Clear",
|
||||||
});
|
});
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
|
screen.getByText("Value = |");
|
||||||
|
await screen.findByText("onChangeCounts = 0|");
|
||||||
|
|
||||||
// Select an option.
|
// Select an option.
|
||||||
await user.click(input);
|
await user.click(input);
|
||||||
@@ -362,6 +507,7 @@ describe("<Select/>", () => {
|
|||||||
|
|
||||||
// Make sure value is selected.
|
// Make sure value is selected.
|
||||||
screen.getByText("Value = new_york|");
|
screen.getByText("Value = new_york|");
|
||||||
|
screen.getByText("onChangeCounts = 1|");
|
||||||
|
|
||||||
// clear value.
|
// clear value.
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
@@ -377,6 +523,7 @@ describe("<Select/>", () => {
|
|||||||
|
|
||||||
screen.getByText("Value = paris|");
|
screen.getByText("Value = paris|");
|
||||||
expect(input).toHaveValue("Paris");
|
expect(input).toHaveValue("Paris");
|
||||||
|
screen.getByText("onChangeCounts = 1|");
|
||||||
});
|
});
|
||||||
it("renders disabled", async () => {
|
it("renders disabled", async () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export const Controlled = () => {
|
|||||||
label="Select a city"
|
label="Select a city"
|
||||||
options={OPTIONS}
|
options={OPTIONS}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value as string)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value as string);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setValue("")}>Reset</Button>
|
<Button onClick={() => setValue("")}>Reset</Button>
|
||||||
<Button onClick={() => setValue(OPTIONS[0].value)}>
|
<Button onClick={() => setValue(OPTIONS[0].value)}>
|
||||||
@@ -159,7 +161,9 @@ export const SearchableControlled = () => {
|
|||||||
options={OPTIONS}
|
options={OPTIONS}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value as string)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value as string);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setValue("")}>Reset</Button>
|
<Button onClick={() => setValue("")}>Reset</Button>
|
||||||
<Button onClick={() => setValue(OPTIONS[0].value)}>
|
<Button onClick={() => setValue(OPTIONS[0].value)}>
|
||||||
|
|||||||
@@ -41,11 +41,19 @@ export const SelectMono = (props: SelectProps) => {
|
|||||||
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
||||||
initialSelectedItem: defaultSelectedItem,
|
initialSelectedItem: defaultSelectedItem,
|
||||||
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
||||||
props.onChange?.({
|
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
|
||||||
target: {
|
const valueCmp = props.value ?? null;
|
||||||
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
// We make sure to not trigger a onChange event if the value are not different.
|
||||||
},
|
// This could happen on first render when the component is controlled, the value will be
|
||||||
});
|
// set inside a useEffect down in SelectMonoSearchable or SelectMonoSimple. So that means the
|
||||||
|
// downshift component will always render empty the first time.
|
||||||
|
if (eventCmp !== valueCmp) {
|
||||||
|
props.onChange?.({
|
||||||
|
target: {
|
||||||
|
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isItemDisabled: (item) => !!item.disabled,
|
isItemDisabled: (item) => !!item.disabled,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ export const expectOptions = (expectedOptions: string[]) => {
|
|||||||
expect(option).toHaveTextContent(expectedOptions[i]);
|
expect(option).toHaveTextContent(expectedOptions[i]);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const expectNoOptions = () => {
|
||||||
|
const options = screen.getAllByRole("listitem");
|
||||||
|
expect(options.length).toBe(1);
|
||||||
|
expect(options[0]).toHaveTextContent("No options available");
|
||||||
|
};
|
||||||
export const expectMenuToBeClosed = (menu: HTMLDivElement) => {
|
export const expectMenuToBeClosed = (menu: HTMLDivElement) => {
|
||||||
expect(Array.from(menu.classList)).not.contains("c__select__menu--opened");
|
expect(Array.from(menu.classList)).not.contains("c__select__menu--opened");
|
||||||
};
|
};
|
||||||
export const expectOptionToBeSelected = (option: HTMLLIElement) => {
|
export const expectOptionToBeSelected = (option: HTMLLIElement) => {
|
||||||
expect(option).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(Array.from(option.classList)).contains(
|
expect(Array.from(option.classList)).contains(
|
||||||
"c__select__menu__item--selected",
|
"c__select__menu__item--selected",
|
||||||
);
|
);
|
||||||
expect(Array.from(option.classList)).contains(
|
|
||||||
"c__select__menu__item--highlight",
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
export const expectOptionToBeUnselected = (option: HTMLLIElement) => {
|
export const expectOptionToBeUnselected = (option: HTMLLIElement) => {
|
||||||
expect(option).toHaveAttribute("aria-selected", "false");
|
expect(option).toHaveAttribute("aria-selected", "false");
|
||||||
|
|||||||
Reference in New Issue
Block a user