✨(react) add ref to Select
We encountered a use-case where we needed to blur the select programatically but the component wasn't offering any way to do that.
This commit is contained in:
5
.changeset/twenty-bulldogs-whisper.md
Normal file
5
.changeset/twenty-bulldogs-whisper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@openfun/cunningham-react": minor
|
||||
---
|
||||
|
||||
add ref to Select
|
||||
@@ -1,16 +1,23 @@
|
||||
import React from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { SelectMulti } from ":/components/Forms/Select/multi";
|
||||
import { SelectMono, SelectProps } from ":/components/Forms/Select/mono";
|
||||
|
||||
export * from ":/components/Forms/Select/mono";
|
||||
export * from ":/components/Forms/Select/multi";
|
||||
|
||||
export const Select = (props: SelectProps) => {
|
||||
export interface SelectHandle {
|
||||
blur: () => void;
|
||||
}
|
||||
export const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => {
|
||||
if (props.defaultValue && props.value) {
|
||||
throw new Error(
|
||||
"You cannot use both defaultValue and value props on Select component",
|
||||
);
|
||||
}
|
||||
|
||||
return props.multi ? <SelectMulti {...props} /> : <SelectMono {...props} />;
|
||||
};
|
||||
return props.multi ? (
|
||||
<SelectMulti {...props} ref={ref} />
|
||||
) : (
|
||||
<SelectMono {...props} ref={ref} />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { useCunningham } from ":/components/Provider";
|
||||
import {
|
||||
@@ -8,119 +14,129 @@ import {
|
||||
SelectMonoAux,
|
||||
SubProps,
|
||||
} from ":/components/Forms/Select/mono-common";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export const SelectMonoSearchable = (props: SubProps) => {
|
||||
const { t } = useCunningham();
|
||||
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||
const [inputFilter, setInputFilter] = useState<string>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const downshiftReturn = useCombobox({
|
||||
...props.downshiftProps,
|
||||
items: optionsToDisplay,
|
||||
itemToString: optionToString,
|
||||
onInputValueChange: (e) => {
|
||||
setInputFilter(e.inputValue);
|
||||
if (!e.inputValue) {
|
||||
downshiftReturn.selectItem(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
|
||||
!downshiftReturn.selectedItem,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (hasInputFocused || downshiftReturn.inputValue) {
|
||||
setLabelAsPlaceholder(false);
|
||||
return;
|
||||
}
|
||||
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
|
||||
}, [
|
||||
downshiftReturn.selectedItem,
|
||||
hasInputFocused,
|
||||
downshiftReturn.inputValue,
|
||||
]);
|
||||
|
||||
// When component is controlled, this useEffect will update the local selected item.
|
||||
useEffect(() => {
|
||||
if (inputFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItem = downshiftReturn.selectedItem
|
||||
? optionToValue(downshiftReturn.selectedItem)
|
||||
: undefined;
|
||||
|
||||
const optionToSelect = props.options.find(
|
||||
(option) => optionToValue(option) === props.value,
|
||||
export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
|
||||
(props, ref) => {
|
||||
const { t } = useCunningham();
|
||||
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||
const [inputFilter, setInputFilter] = useState<string>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const downshiftReturn = useCombobox({
|
||||
...props.downshiftProps,
|
||||
items: optionsToDisplay,
|
||||
itemToString: optionToString,
|
||||
onInputValueChange: (e) => {
|
||||
setInputFilter(e.inputValue);
|
||||
if (!e.inputValue) {
|
||||
downshiftReturn.selectItem(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
|
||||
!downshiftReturn.selectedItem,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (hasInputFocused || downshiftReturn.inputValue) {
|
||||
setLabelAsPlaceholder(false);
|
||||
return;
|
||||
}
|
||||
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
|
||||
}, [
|
||||
downshiftReturn.selectedItem,
|
||||
hasInputFocused,
|
||||
downshiftReturn.inputValue,
|
||||
]);
|
||||
|
||||
// Already selected
|
||||
if (optionToSelect && selectedItem === props.value) {
|
||||
return;
|
||||
}
|
||||
// When component is controlled, this useEffect will update the local selected item.
|
||||
useEffect(() => {
|
||||
if (inputFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||
}, [props.value, props.options, inputFilter]);
|
||||
const selectedItem = downshiftReturn.selectedItem
|
||||
? optionToValue(downshiftReturn.selectedItem)
|
||||
: undefined;
|
||||
|
||||
// Even there is already a value selected, when opening the combobox menu we want to display all available choices.
|
||||
useEffect(() => {
|
||||
if (downshiftReturn.isOpen) {
|
||||
setOptionsToDisplay(
|
||||
inputFilter
|
||||
? props.options.filter(getOptionsFilter(inputFilter))
|
||||
: props.options,
|
||||
const optionToSelect = props.options.find(
|
||||
(option) => optionToValue(option) === props.value,
|
||||
);
|
||||
} else {
|
||||
setInputFilter(undefined);
|
||||
}
|
||||
}, [downshiftReturn.isOpen, props.options, inputFilter]);
|
||||
|
||||
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("");
|
||||
}
|
||||
};
|
||||
// Already selected
|
||||
if (optionToSelect && selectedItem === props.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputProps = downshiftReturn.getInputProps({
|
||||
ref: inputRef,
|
||||
disabled: props.disabled,
|
||||
});
|
||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||
}, [props.value, props.options, inputFilter]);
|
||||
|
||||
return (
|
||||
<SelectMonoAux
|
||||
{...props}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
inputRef.current?.focus();
|
||||
downshiftReturn.openMenu();
|
||||
// Even there is already a value selected, when opening the combobox menu we want to display all available choices.
|
||||
useEffect(() => {
|
||||
if (downshiftReturn.isOpen) {
|
||||
setOptionsToDisplay(
|
||||
inputFilter
|
||||
? props.options.filter(getOptionsFilter(inputFilter))
|
||||
: props.options,
|
||||
);
|
||||
} else {
|
||||
setInputFilter(undefined);
|
||||
}
|
||||
}, [downshiftReturn.isOpen, props.options, inputFilter]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
blur: () => {
|
||||
downshiftReturn.closeMenu();
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
}));
|
||||
|
||||
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({
|
||||
ref: inputRef,
|
||||
disabled: props.disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectMonoAux
|
||||
{...props}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
inputRef.current?.focus();
|
||||
downshiftReturn.openMenu();
|
||||
},
|
||||
},
|
||||
},
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||
disabled: props.disabled,
|
||||
"aria-label": t("components.forms.select.toggle_button_aria_label"),
|
||||
}),
|
||||
}}
|
||||
labelAsPlaceholder={labelAsPlaceholder}
|
||||
options={optionsToDisplay}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
onFocus={() => {
|
||||
setHasInputFocused(true);
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||
disabled: props.disabled,
|
||||
"aria-label": t("components.forms.select.toggle_button_aria_label"),
|
||||
}),
|
||||
}}
|
||||
onBlur={() => {
|
||||
onInputBlur();
|
||||
}}
|
||||
/>
|
||||
</SelectMonoAux>
|
||||
);
|
||||
};
|
||||
labelAsPlaceholder={labelAsPlaceholder}
|
||||
options={optionsToDisplay}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
onFocus={() => {
|
||||
setHasInputFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
onInputBlur();
|
||||
}}
|
||||
/>
|
||||
</SelectMonoAux>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,52 +1,70 @@
|
||||
import { useSelect } from "downshift";
|
||||
import React, { useEffect } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
optionToString,
|
||||
optionToValue,
|
||||
SelectMonoAux,
|
||||
SubProps,
|
||||
} from ":/components/Forms/Select/mono-common";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export const SelectMonoSimple = (props: SubProps) => {
|
||||
const downshiftReturn = useSelect({
|
||||
...props.downshiftProps,
|
||||
items: props.options,
|
||||
itemToString: optionToString,
|
||||
});
|
||||
export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>(
|
||||
(props, ref) => {
|
||||
const downshiftReturn = useSelect({
|
||||
...props.downshiftProps,
|
||||
items: props.options,
|
||||
itemToString: optionToString,
|
||||
});
|
||||
|
||||
// When component is controlled, this useEffect will update the local selected item.
|
||||
useEffect(() => {
|
||||
const selectedItem = downshiftReturn.selectedItem
|
||||
? optionToValue(downshiftReturn.selectedItem)
|
||||
: undefined;
|
||||
// When component is controlled, this useEffect will update the local selected item.
|
||||
useEffect(() => {
|
||||
const selectedItem = downshiftReturn.selectedItem
|
||||
? optionToValue(downshiftReturn.selectedItem)
|
||||
: undefined;
|
||||
|
||||
const optionToSelect = props.options.find(
|
||||
(option) => optionToValue(option) === props.value,
|
||||
const optionToSelect = props.options.find(
|
||||
(option) => optionToValue(option) === props.value,
|
||||
);
|
||||
|
||||
// Already selected
|
||||
if (optionToSelect && selectedItem === props.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||
}, [props.value, props.options]);
|
||||
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
blur: () => {
|
||||
downshiftReturn.closeMenu();
|
||||
wrapperRef.current?.blur();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectMonoAux
|
||||
{...props}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: downshiftReturn.getToggleButtonProps({
|
||||
disabled: props.disabled,
|
||||
ref: wrapperRef,
|
||||
}),
|
||||
toggleButtonProps: {},
|
||||
}}
|
||||
labelAsPlaceholder={!downshiftReturn.selectedItem}
|
||||
>
|
||||
{downshiftReturn.selectedItem && (
|
||||
<span>{optionToString(downshiftReturn.selectedItem)}</span>
|
||||
)}
|
||||
</SelectMonoAux>
|
||||
);
|
||||
|
||||
// Already selected
|
||||
if (optionToSelect && selectedItem === props.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||
}, [props.value, props.options]);
|
||||
|
||||
return (
|
||||
<SelectMonoAux
|
||||
{...props}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: downshiftReturn.getToggleButtonProps({
|
||||
disabled: props.disabled,
|
||||
}),
|
||||
toggleButtonProps: {},
|
||||
}}
|
||||
labelAsPlaceholder={!downshiftReturn.selectedItem}
|
||||
>
|
||||
{downshiftReturn.selectedItem && (
|
||||
<span>{optionToString(downshiftReturn.selectedItem)}</span>
|
||||
)}
|
||||
</SelectMonoAux>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { expect } from "vitest";
|
||||
import React, { FormEvent, useState } from "react";
|
||||
import { Select, Option } from ":/components/Forms/Select/index";
|
||||
import React, { createRef, FormEvent, useState } from "react";
|
||||
import { Select, Option, SelectHandle } from ":/components/Forms/Select/index";
|
||||
import { Button } from ":/components/Button";
|
||||
import { CunninghamProvider } from ":/components/Provider";
|
||||
import {
|
||||
@@ -787,6 +787,55 @@ describe("<Select/>", () => {
|
||||
expect(input).toHaveValue("London");
|
||||
});
|
||||
});
|
||||
|
||||
it("blurs from ref", async () => {
|
||||
const ref = createRef<SelectHandle>();
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Select
|
||||
label="City"
|
||||
options={[
|
||||
{
|
||||
label: "Paris",
|
||||
value: "paris",
|
||||
},
|
||||
{
|
||||
label: "Panama",
|
||||
value: "panama",
|
||||
},
|
||||
{
|
||||
label: "London",
|
||||
value: "london",
|
||||
},
|
||||
]}
|
||||
searchable={true}
|
||||
ref={ref}
|
||||
/>
|
||||
</CunninghamProvider>,
|
||||
);
|
||||
const input = screen.getByRole("combobox", {
|
||||
name: "City",
|
||||
});
|
||||
const menu: HTMLDivElement = screen.getByRole("listbox", {
|
||||
name: "City",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Make sure the select is not focused.
|
||||
expect(document.activeElement?.tagName).toEqual("BODY");
|
||||
|
||||
// Focus the select by focusing input.
|
||||
await user.click(input);
|
||||
expectMenuToBeOpen(menu);
|
||||
expect(document.activeElement?.tagName).toEqual("INPUT");
|
||||
|
||||
// Blur the select.
|
||||
ref.current?.blur();
|
||||
|
||||
// Make sure the select is blured.
|
||||
await waitFor(() => expectMenuToBeClosed(menu));
|
||||
expect(document.activeElement?.tagName).toEqual("BODY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Simple", () => {
|
||||
@@ -1547,5 +1596,53 @@ describe("<Select/>", () => {
|
||||
screen.getByText("Value = |");
|
||||
screen.getByText("onChangeCounts = 2|");
|
||||
});
|
||||
|
||||
it("blurs from ref", async () => {
|
||||
const ref = createRef<SelectHandle>();
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Select
|
||||
label="City"
|
||||
options={[
|
||||
{
|
||||
label: "Paris",
|
||||
value: "paris",
|
||||
},
|
||||
{
|
||||
label: "Panama",
|
||||
value: "panama",
|
||||
},
|
||||
{
|
||||
label: "London",
|
||||
value: "london",
|
||||
},
|
||||
]}
|
||||
ref={ref}
|
||||
/>
|
||||
</CunninghamProvider>,
|
||||
);
|
||||
const input = screen.getByRole("combobox", {
|
||||
name: "City",
|
||||
});
|
||||
const menu: HTMLDivElement = screen.getByRole("listbox", {
|
||||
name: "City",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Make sure the select is not focused.
|
||||
expect(document.activeElement?.className).toEqual("");
|
||||
|
||||
// Focus the select.
|
||||
await user.click(input);
|
||||
expectMenuToBeOpen(menu);
|
||||
expect(document.activeElement?.className).toContain("c__select__wrapper");
|
||||
|
||||
// Blur the select.
|
||||
ref.current?.blur();
|
||||
|
||||
// Make sure the select is blured.
|
||||
await waitFor(() => expectMenuToBeClosed(menu));
|
||||
expect(document.activeElement?.className).toEqual("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import * as Yup from "yup";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils";
|
||||
import { Select } from ":/components/Forms/Select";
|
||||
import { Select, SelectHandle } from ":/components/Forms/Select";
|
||||
import { Button } from ":/components/Button";
|
||||
import { RhfSelect } from ":/components/Forms/Select/stories-utils";
|
||||
|
||||
@@ -228,6 +228,57 @@ export const NoOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Ref = () => {
|
||||
const ref = useRef<SelectHandle>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-s">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("calling blur() ...");
|
||||
ref.current?.blur();
|
||||
}, 2000)
|
||||
}
|
||||
>
|
||||
Trigger onBlur in 2 seconds
|
||||
</Button>
|
||||
</div>
|
||||
<Select label="Select a city" options={OPTIONS} ref={ref} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchableRef = () => {
|
||||
const ref = useRef<SelectHandle>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-s">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("calling blur() ...");
|
||||
ref.current?.blur();
|
||||
}, 2000)
|
||||
}
|
||||
>
|
||||
Trigger onBlur in 2 seconds
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
label="Select a city"
|
||||
options={OPTIONS}
|
||||
searchable={true}
|
||||
ref={ref}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormExample = () => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { PropsWithChildren, useEffect, useState } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { UseSelectStateChange } from "downshift";
|
||||
import { FieldProps } from ":/components/Forms/Field";
|
||||
import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common";
|
||||
import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable";
|
||||
import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export interface Option {
|
||||
value?: string;
|
||||
@@ -31,59 +37,63 @@ export type SelectProps = PropsWithChildren &
|
||||
multi?: boolean;
|
||||
};
|
||||
|
||||
export const SelectMono = (props: SelectProps) => {
|
||||
const defaultSelectedItem = props.defaultValue
|
||||
? props.options.find(
|
||||
(option) => optionToValue(option) === props.defaultValue,
|
||||
)
|
||||
: undefined;
|
||||
const [value, setValue] = useState(
|
||||
defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value,
|
||||
);
|
||||
export const SelectMono = forwardRef<SelectHandle, SelectProps>(
|
||||
(props, ref) => {
|
||||
const defaultSelectedItem = props.defaultValue
|
||||
? props.options.find(
|
||||
(option) => optionToValue(option) === props.defaultValue,
|
||||
)
|
||||
: undefined;
|
||||
const [value, setValue] = useState(
|
||||
defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value,
|
||||
);
|
||||
|
||||
/**
|
||||
* This useEffect is used to update the local value when the component is controlled.
|
||||
* The defaultValue is used only on first render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (props.defaultValue) {
|
||||
return;
|
||||
}
|
||||
setValue(props.value);
|
||||
}, [props.value, props.defaultValue]);
|
||||
|
||||
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
||||
initialSelectedItem: defaultSelectedItem,
|
||||
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
||||
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
|
||||
const valueCmp = value ?? null;
|
||||
// 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) {
|
||||
setValue(eventCmp || undefined);
|
||||
props.onChange?.({
|
||||
target: {
|
||||
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* This useEffect is used to update the local value when the component is controlled.
|
||||
* The defaultValue is used only on first render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (props.defaultValue) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
};
|
||||
setValue(props.value);
|
||||
}, [props.value, props.defaultValue]);
|
||||
|
||||
return props.searchable ? (
|
||||
<SelectMonoSearchable
|
||||
{...props}
|
||||
downshiftProps={commonDownshiftProps}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<SelectMonoSimple
|
||||
{...props}
|
||||
downshiftProps={commonDownshiftProps}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
||||
initialSelectedItem: defaultSelectedItem,
|
||||
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
||||
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
|
||||
const valueCmp = value ?? null;
|
||||
// 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) {
|
||||
setValue(eventCmp || undefined);
|
||||
props.onChange?.({
|
||||
target: {
|
||||
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
};
|
||||
|
||||
return props.searchable ? (
|
||||
<SelectMonoSearchable
|
||||
{...props}
|
||||
downshiftProps={commonDownshiftProps}
|
||||
value={value}
|
||||
ref={ref}
|
||||
/>
|
||||
) : (
|
||||
<SelectMonoSimple
|
||||
{...props}
|
||||
downshiftProps={commonDownshiftProps}
|
||||
value={value}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCombobox, useMultipleSelection } from "downshift";
|
||||
import { optionToString } from ":/components/Forms/Select/mono-common";
|
||||
import {
|
||||
@@ -6,138 +12,152 @@ import {
|
||||
SelectMultiAux,
|
||||
SubProps,
|
||||
} from ":/components/Forms/Select/multi-common";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export const SelectMultiSearchable = (props: SubProps) => {
|
||||
const [inputValue, setInputValue] = React.useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
props.options.filter(
|
||||
getMultiOptionsFilter(props.selectedItems, inputValue),
|
||||
),
|
||||
[props.selectedItems, inputValue],
|
||||
);
|
||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||
const useMultipleSelectionReturn = useMultipleSelection({
|
||||
selectedItems: props.selectedItems,
|
||||
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
const downshiftReturn = useCombobox({
|
||||
items: options,
|
||||
itemToString: optionToString,
|
||||
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true, // keep the menu open after selection.
|
||||
highlightedIndex: 0, // with the first option highlighted.
|
||||
};
|
||||
default:
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
onStateChange: ({
|
||||
inputValue: newInputValue,
|
||||
type,
|
||||
selectedItem: newSelectedItem,
|
||||
}) => {
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
if (newSelectedItem && !newSelectedItem.disabled) {
|
||||
props.onSelectedItemsChange([
|
||||
...props.selectedItems,
|
||||
newSelectedItem,
|
||||
]);
|
||||
setInputValue("");
|
||||
}
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? "");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
});
|
||||
|
||||
const inputProps = downshiftReturn.getInputProps({
|
||||
...useMultipleSelectionReturn.getDropdownProps({
|
||||
preventKeyAction: downshiftReturn.isOpen,
|
||||
ref: inputRef,
|
||||
disabled: props.disabled,
|
||||
}),
|
||||
value: inputValue,
|
||||
});
|
||||
// We want to extend the default behavior of the input onKeyDown.
|
||||
const { onKeyDown } = inputProps;
|
||||
inputProps.onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
switch (event.code) {
|
||||
case "Backspace":
|
||||
if (!inputValue) {
|
||||
props.onSelectedItemsChange(props.selectedItems.slice(0, -1));
|
||||
export const SelectMultiSearchable = forwardRef<SelectHandle, SubProps>(
|
||||
(props, ref) => {
|
||||
const [inputValue, setInputValue] = React.useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
props.options.filter(
|
||||
getMultiOptionsFilter(props.selectedItems, inputValue),
|
||||
),
|
||||
[props.selectedItems, inputValue],
|
||||
);
|
||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||
const useMultipleSelectionReturn = useMultipleSelection({
|
||||
selectedItems: props.selectedItems,
|
||||
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes
|
||||
.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
},
|
||||
});
|
||||
const downshiftReturn = useCombobox({
|
||||
items: options,
|
||||
itemToString: optionToString,
|
||||
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true, // keep the menu open after selection.
|
||||
highlightedIndex: 0, // with the first option highlighted.
|
||||
};
|
||||
default:
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
onStateChange: ({
|
||||
inputValue: newInputValue,
|
||||
type,
|
||||
selectedItem: newSelectedItem,
|
||||
}) => {
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
if (newSelectedItem && !newSelectedItem.disabled) {
|
||||
props.onSelectedItemsChange([
|
||||
...props.selectedItems,
|
||||
newSelectedItem,
|
||||
]);
|
||||
setInputValue("");
|
||||
}
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? "");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
});
|
||||
|
||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true);
|
||||
useEffect(() => {
|
||||
if (hasInputFocused || inputValue) {
|
||||
setLabelAsPlaceholder(false);
|
||||
return;
|
||||
}
|
||||
setLabelAsPlaceholder(props.selectedItems.length === 0);
|
||||
}, [props.selectedItems, hasInputFocused, inputValue]);
|
||||
const inputProps = downshiftReturn.getInputProps({
|
||||
...useMultipleSelectionReturn.getDropdownProps({
|
||||
preventKeyAction: downshiftReturn.isOpen,
|
||||
ref: inputRef,
|
||||
disabled: props.disabled,
|
||||
}),
|
||||
value: inputValue,
|
||||
});
|
||||
// We want to extend the default behavior of the input onKeyDown.
|
||||
const { onKeyDown } = inputProps;
|
||||
inputProps.onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
switch (event.code) {
|
||||
case "Backspace":
|
||||
if (!inputValue) {
|
||||
props.onSelectedItemsChange(props.selectedItems.slice(0, -1));
|
||||
}
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectMultiAux
|
||||
{...props}
|
||||
options={options}
|
||||
labelAsPlaceholder={labelAsPlaceholder}
|
||||
selectedItems={props.selectedItems}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
inputRef.current?.focus();
|
||||
downshiftReturn.openMenu();
|
||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true);
|
||||
useEffect(() => {
|
||||
if (hasInputFocused || inputValue) {
|
||||
setLabelAsPlaceholder(false);
|
||||
return;
|
||||
}
|
||||
setLabelAsPlaceholder(props.selectedItems.length === 0);
|
||||
}, [props.selectedItems, hasInputFocused, inputValue]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
blur: () => {
|
||||
downshiftReturn.closeMenu();
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectMultiAux
|
||||
{...props}
|
||||
options={options}
|
||||
labelAsPlaceholder={labelAsPlaceholder}
|
||||
selectedItems={props.selectedItems}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
inputRef.current?.focus();
|
||||
downshiftReturn.openMenu();
|
||||
},
|
||||
},
|
||||
},
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps(),
|
||||
}}
|
||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||
>
|
||||
<span className="c__select__inner__value__input" data-value={inputValue}>
|
||||
<input
|
||||
{...inputProps}
|
||||
onFocus={() => {
|
||||
setHasInputFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasInputFocused(false);
|
||||
}}
|
||||
size={4}
|
||||
/>
|
||||
</span>
|
||||
</SelectMultiAux>
|
||||
);
|
||||
};
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps(),
|
||||
}}
|
||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||
>
|
||||
<span
|
||||
className="c__select__inner__value__input"
|
||||
data-value={inputValue}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
onFocus={() => {
|
||||
setHasInputFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHasInputFocused(false);
|
||||
}}
|
||||
size={4}
|
||||
/>
|
||||
</span>
|
||||
</SelectMultiAux>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { useMultipleSelection, useSelect } from "downshift";
|
||||
import {
|
||||
getMultiOptionsFilter,
|
||||
@@ -6,95 +6,110 @@ import {
|
||||
SubProps,
|
||||
} from ":/components/Forms/Select/multi-common";
|
||||
import { optionToString } from ":/components/Forms/Select/mono-common";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export const SelectMultiSimple = (props: SubProps) => {
|
||||
const options = React.useMemo(
|
||||
() => props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
|
||||
[props.selectedItems],
|
||||
);
|
||||
export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
|
||||
(props, ref) => {
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
|
||||
[props.selectedItems],
|
||||
);
|
||||
|
||||
const useMultipleSelectionReturn = useMultipleSelection({
|
||||
selectedItems: props.selectedItems,
|
||||
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
const useMultipleSelectionReturn = useMultipleSelection({
|
||||
selectedItems: props.selectedItems,
|
||||
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes
|
||||
.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const downshiftReturn = useSelect({
|
||||
items: options,
|
||||
itemToString: optionToString,
|
||||
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
|
||||
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch (type) {
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||
case useSelect.stateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true, // keep the menu open after selection.
|
||||
highlightedIndex: 0, // with the first option highlighted.
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
|
||||
switch (type) {
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||
case useSelect.stateChangeTypes.ItemClick:
|
||||
if (newSelectedItem) {
|
||||
props.onSelectedItemsChange([
|
||||
...props.selectedItems,
|
||||
newSelectedItem,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectMultiAux
|
||||
{...props}
|
||||
options={options}
|
||||
labelAsPlaceholder={props.selectedItems.length === 0}
|
||||
selectedItems={props.selectedItems}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
if (!props.disabled) {
|
||||
downshiftReturn.toggleMenu();
|
||||
const downshiftReturn = useSelect({
|
||||
items: options,
|
||||
itemToString: optionToString,
|
||||
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
|
||||
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch (type) {
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||
case useSelect.stateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true, // keep the menu open after selection.
|
||||
highlightedIndex: 0, // with the first option highlighted.
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
|
||||
switch (type) {
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||
case useSelect.stateChangeTypes.ItemClick:
|
||||
if (newSelectedItem) {
|
||||
props.onSelectedItemsChange([
|
||||
...props.selectedItems,
|
||||
newSelectedItem,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
isItemDisabled: (item) => !!item.disabled,
|
||||
});
|
||||
|
||||
const toggleRef = useRef<HTMLElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
blur: () => {
|
||||
downshiftReturn.closeMenu();
|
||||
toggleRef.current?.blur();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectMultiAux
|
||||
{...props}
|
||||
options={options}
|
||||
labelAsPlaceholder={props.selectedItems.length === 0}
|
||||
selectedItems={props.selectedItems}
|
||||
downshiftReturn={{
|
||||
...downshiftReturn,
|
||||
wrapperProps: {
|
||||
onClick: () => {
|
||||
if (!props.disabled) {
|
||||
downshiftReturn.toggleMenu();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||
...useMultipleSelectionReturn.getDropdownProps({
|
||||
preventKeyAction: downshiftReturn.isOpen,
|
||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||
...useMultipleSelectionReturn.getDropdownProps({
|
||||
preventKeyAction: downshiftReturn.isOpen,
|
||||
ref: toggleRef,
|
||||
}),
|
||||
disabled: props.disabled,
|
||||
onClick: (e: React.MouseEvent): void => {
|
||||
// As the wrapper also has an onClick handler, we need to stop the event propagation here on it will toggle
|
||||
// twice the menu opening which will ... do nothing :).
|
||||
e.stopPropagation();
|
||||
},
|
||||
}),
|
||||
disabled: props.disabled,
|
||||
onClick: (e: React.MouseEvent): void => {
|
||||
// As the wrapper also has an onClick handler, we need to stop the event propagation here on it will toggle
|
||||
// twice the menu opening which will ... do nothing :).
|
||||
e.stopPropagation();
|
||||
},
|
||||
}),
|
||||
}}
|
||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}}
|
||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import React, { FormEvent, useState } from "react";
|
||||
import React, { createRef, FormEvent, useState } from "react";
|
||||
import { expect } from "vitest";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { CunninghamProvider } from ":/components/Provider";
|
||||
import { Select } from ":/components/Forms/Select/index";
|
||||
import { Select, SelectHandle } from ":/components/Forms/Select/index";
|
||||
import {
|
||||
expectMenuToBeClosed,
|
||||
expectMenuToBeOpen,
|
||||
@@ -785,6 +785,55 @@ describe("<Select multi={true} />", () => {
|
||||
);
|
||||
expectSelectedOptions(["Paris"]);
|
||||
});
|
||||
|
||||
it("blurs from ref", async () => {
|
||||
const ref = createRef<SelectHandle>();
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Select
|
||||
label="City"
|
||||
options={[
|
||||
{
|
||||
label: "Paris",
|
||||
value: "paris",
|
||||
},
|
||||
{
|
||||
label: "Panama",
|
||||
value: "panama",
|
||||
},
|
||||
{
|
||||
label: "London",
|
||||
value: "london",
|
||||
},
|
||||
]}
|
||||
multi={true}
|
||||
ref={ref}
|
||||
/>
|
||||
</CunninghamProvider>,
|
||||
);
|
||||
const input = screen.getByRole("combobox", {
|
||||
name: "City",
|
||||
});
|
||||
const menu: HTMLDivElement = screen.getByRole("listbox", {
|
||||
name: "City",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Make sure the select is not focused.
|
||||
expect(document.activeElement?.className).toEqual("");
|
||||
|
||||
// Focus the select.
|
||||
await user.click(input);
|
||||
expectMenuToBeOpen(menu);
|
||||
expect(document.activeElement?.className).toContain("c__button");
|
||||
|
||||
// Blur the select.
|
||||
ref.current?.blur();
|
||||
|
||||
// Make sure the select is blured.
|
||||
await waitFor(() => expectMenuToBeClosed(menu));
|
||||
expect(document.activeElement?.className).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Searchable", async () => {
|
||||
@@ -1343,5 +1392,55 @@ describe("<Select multi={true} />", () => {
|
||||
|
||||
screen.getByText("No options available");
|
||||
});
|
||||
|
||||
it("blurs from ref", async () => {
|
||||
const ref = createRef<SelectHandle>();
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Select
|
||||
label="City"
|
||||
options={[
|
||||
{
|
||||
label: "Paris",
|
||||
value: "paris",
|
||||
},
|
||||
{
|
||||
label: "Panama",
|
||||
value: "panama",
|
||||
},
|
||||
{
|
||||
label: "London",
|
||||
value: "london",
|
||||
},
|
||||
]}
|
||||
multi={true}
|
||||
searchable={true}
|
||||
ref={ref}
|
||||
/>
|
||||
</CunninghamProvider>,
|
||||
);
|
||||
const input = screen.getByRole("combobox", {
|
||||
name: "City",
|
||||
});
|
||||
const menu: HTMLDivElement = screen.getByRole("listbox", {
|
||||
name: "City",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Make sure the select is not focused.
|
||||
expect(document.activeElement?.tagName).toEqual("BODY");
|
||||
|
||||
// Focus the select by focusing input.
|
||||
await user.click(input);
|
||||
expectMenuToBeOpen(menu);
|
||||
expect(document.activeElement?.tagName).toEqual("INPUT");
|
||||
|
||||
// Blur the select.
|
||||
ref.current?.blur();
|
||||
|
||||
// Make sure the select is blured.
|
||||
await waitFor(() => expectMenuToBeClosed(menu));
|
||||
expect(document.activeElement?.tagName).toEqual("BODY");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import * as Yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils";
|
||||
import { Select } from ":/components/Forms/Select";
|
||||
import { Select, SelectHandle } from ":/components/Forms/Select";
|
||||
import { Button } from ":/components/Button";
|
||||
import { RhfSelect } from ":/components/Forms/Select/stories-utils";
|
||||
|
||||
@@ -216,6 +216,58 @@ export const NoOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Ref = () => {
|
||||
const ref = useRef<SelectHandle>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-s">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("calling blur() ...");
|
||||
ref.current?.blur();
|
||||
}, 2000)
|
||||
}
|
||||
>
|
||||
Trigger onBlur in 2 seconds
|
||||
</Button>
|
||||
</div>
|
||||
<Select label="Select a city" options={OPTIONS} multi={true} ref={ref} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchableRef = () => {
|
||||
const ref = useRef<SelectHandle>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-s">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("calling blur() ...");
|
||||
ref.current?.blur();
|
||||
}, 2000)
|
||||
}
|
||||
>
|
||||
Trigger onBlur in 2 seconds
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
label="Select a city"
|
||||
options={OPTIONS}
|
||||
multi={true}
|
||||
searchable={true}
|
||||
ref={ref}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormExample = () => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { forwardRef, useEffect } from "react";
|
||||
import { optionToValue } from ":/components/Forms/Select/mono-common";
|
||||
import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable";
|
||||
import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple";
|
||||
import { SubProps } from ":/components/Forms/Select/multi-common";
|
||||
import { Option, SelectProps } from ":/components/Forms/Select/mono";
|
||||
import { SelectHandle } from ":/components/Forms/Select/index";
|
||||
|
||||
export type SelectMultiProps = Omit<SelectProps, "onChange"> & {
|
||||
onChange?: (event: { target: { value: string[] } }) => void;
|
||||
};
|
||||
|
||||
export const SelectMulti = (props: SelectMultiProps) => {
|
||||
const getSelectedItemsFromProps = () => {
|
||||
const valueToUse = props.defaultValue ?? props.value ?? [];
|
||||
return props.options.filter((option) =>
|
||||
(valueToUse as string[]).includes(optionToValue(option)),
|
||||
export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>(
|
||||
(props, ref) => {
|
||||
const getSelectedItemsFromProps = () => {
|
||||
const valueToUse = props.defaultValue ?? props.value ?? [];
|
||||
return props.options.filter((option) =>
|
||||
(valueToUse as string[]).includes(optionToValue(option)),
|
||||
);
|
||||
};
|
||||
|
||||
const [selectedItems, setSelectedItems] = React.useState<Option[]>(
|
||||
getSelectedItemsFromProps(),
|
||||
);
|
||||
};
|
||||
|
||||
const [selectedItems, setSelectedItems] = React.useState<Option[]>(
|
||||
getSelectedItemsFromProps(),
|
||||
);
|
||||
// If the component is used as a controlled component, we need to update the local value when the value prop changes.
|
||||
useEffect(() => {
|
||||
// Means it is not controlled.
|
||||
if (props.defaultValue !== undefined) {
|
||||
return;
|
||||
}
|
||||
setSelectedItems(getSelectedItemsFromProps());
|
||||
}, [JSON.stringify(props.value)]);
|
||||
|
||||
// If the component is used as a controlled component, we need to update the local value when the value prop changes.
|
||||
useEffect(() => {
|
||||
// Means it is not controlled.
|
||||
if (props.defaultValue !== undefined) {
|
||||
return;
|
||||
}
|
||||
setSelectedItems(getSelectedItemsFromProps());
|
||||
}, [JSON.stringify(props.value)]);
|
||||
// If the component is used as an uncontrolled component, we need to update the parent value when the local value changes.
|
||||
useEffect(() => {
|
||||
props.onChange?.({ target: { value: selectedItems.map(optionToValue) } });
|
||||
}, [JSON.stringify(selectedItems)]);
|
||||
|
||||
// If the component is used as an uncontrolled component, we need to update the parent value when the local value changes.
|
||||
useEffect(() => {
|
||||
props.onChange?.({ target: { value: selectedItems.map(optionToValue) } });
|
||||
}, [JSON.stringify(selectedItems)]);
|
||||
const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = (
|
||||
newSelectedItems,
|
||||
) => {
|
||||
setSelectedItems(newSelectedItems);
|
||||
// props.onSelectedItemsChange?.(newSelectedItems);
|
||||
};
|
||||
|
||||
const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = (
|
||||
newSelectedItems,
|
||||
) => {
|
||||
setSelectedItems(newSelectedItems);
|
||||
// props.onSelectedItemsChange?.(newSelectedItems);
|
||||
};
|
||||
|
||||
return props.searchable ? (
|
||||
<SelectMultiSearchable
|
||||
{...props}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={onSelectedItemsChange}
|
||||
/>
|
||||
) : (
|
||||
<SelectMultiSimple
|
||||
{...props}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={onSelectedItemsChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return props.searchable ? (
|
||||
<SelectMultiSearchable
|
||||
{...props}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={onSelectedItemsChange}
|
||||
ref={ref}
|
||||
/>
|
||||
) : (
|
||||
<SelectMultiSimple
|
||||
{...props}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={onSelectedItemsChange}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user