(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:
Nathan Vasse
2023-10-03 17:02:23 +02:00
committed by NathanVss
parent d647a77c58
commit 1c7a114b6e
12 changed files with 869 additions and 474 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
add ref to Select

View File

@@ -1,16 +1,23 @@
import React from "react"; import React, { forwardRef } from "react";
import { SelectMulti } from ":/components/Forms/Select/multi"; import { SelectMulti } from ":/components/Forms/Select/multi";
import { SelectMono, SelectProps } from ":/components/Forms/Select/mono"; import { SelectMono, SelectProps } from ":/components/Forms/Select/mono";
export * from ":/components/Forms/Select/mono"; export * from ":/components/Forms/Select/mono";
export * from ":/components/Forms/Select/multi"; 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) { if (props.defaultValue && props.value) {
throw new Error( throw new Error(
"You cannot use both defaultValue and value props on Select component", "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} />
);
});

View File

@@ -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 { useCombobox } from "downshift";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
import { import {
@@ -8,119 +14,129 @@ import {
SelectMonoAux, SelectMonoAux,
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMonoSearchable = (props: SubProps) => { export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
const { t } = useCunningham(); (props, ref) => {
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); const { t } = useCunningham();
const [hasInputFocused, setHasInputFocused] = useState(false); const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
const [inputFilter, setInputFilter] = useState<string>(); const [hasInputFocused, setHasInputFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const [inputFilter, setInputFilter] = useState<string>();
const downshiftReturn = useCombobox({ const inputRef = useRef<HTMLInputElement>(null);
...props.downshiftProps, const downshiftReturn = useCombobox({
items: optionsToDisplay, ...props.downshiftProps,
itemToString: optionToString, items: optionsToDisplay,
onInputValueChange: (e) => { itemToString: optionToString,
setInputFilter(e.inputValue); onInputValueChange: (e) => {
if (!e.inputValue) { setInputFilter(e.inputValue);
downshiftReturn.selectItem(null); if (!e.inputValue) {
} downshiftReturn.selectItem(null);
}, }
}); },
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( });
!downshiftReturn.selectedItem, 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,
); );
useEffect(() => {
if (hasInputFocused || downshiftReturn.inputValue) {
setLabelAsPlaceholder(false);
return;
}
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
}, [
downshiftReturn.selectedItem,
hasInputFocused,
downshiftReturn.inputValue,
]);
// Already selected // When component is controlled, this useEffect will update the local selected item.
if (optionToSelect && selectedItem === props.value) { useEffect(() => {
return; if (inputFilter) {
} return;
}
downshiftReturn.selectItem(optionToSelect ?? null); const selectedItem = downshiftReturn.selectedItem
}, [props.value, props.options, inputFilter]); ? optionToValue(downshiftReturn.selectedItem)
: undefined;
// Even there is already a value selected, when opening the combobox menu we want to display all available choices. const optionToSelect = props.options.find(
useEffect(() => { (option) => optionToValue(option) === props.value,
if (downshiftReturn.isOpen) {
setOptionsToDisplay(
inputFilter
? props.options.filter(getOptionsFilter(inputFilter))
: props.options,
); );
} else {
setInputFilter(undefined);
}
}, [downshiftReturn.isOpen, props.options, inputFilter]);
const onInputBlur = () => { // Already selected
setHasInputFocused(false); if (optionToSelect && selectedItem === props.value) {
if (downshiftReturn.selectedItem) { return;
// 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({ downshiftReturn.selectItem(optionToSelect ?? null);
ref: inputRef, }, [props.value, props.options, inputFilter]);
disabled: props.disabled,
});
return ( // Even there is already a value selected, when opening the combobox menu we want to display all available choices.
<SelectMonoAux useEffect(() => {
{...props} if (downshiftReturn.isOpen) {
downshiftReturn={{ setOptionsToDisplay(
...downshiftReturn, inputFilter
wrapperProps: { ? props.options.filter(getOptionsFilter(inputFilter))
onClick: () => { : props.options,
inputRef.current?.focus(); );
downshiftReturn.openMenu(); } 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({
toggleButtonProps: downshiftReturn.getToggleButtonProps({ disabled: props.disabled,
disabled: props.disabled, "aria-label": t("components.forms.select.toggle_button_aria_label"),
"aria-label": t("components.forms.select.toggle_button_aria_label"), }),
}),
}}
labelAsPlaceholder={labelAsPlaceholder}
options={optionsToDisplay}
>
<input
{...inputProps}
onFocus={() => {
setHasInputFocused(true);
}} }}
onBlur={() => { labelAsPlaceholder={labelAsPlaceholder}
onInputBlur(); options={optionsToDisplay}
}} >
/> <input
</SelectMonoAux> {...inputProps}
); onFocus={() => {
}; setHasInputFocused(true);
}}
onBlur={() => {
onInputBlur();
}}
/>
</SelectMonoAux>
);
},
);

View File

@@ -1,52 +1,70 @@
import { useSelect } from "downshift"; import { useSelect } from "downshift";
import React, { useEffect } from "react"; import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { import {
optionToString, optionToString,
optionToValue, optionToValue,
SelectMonoAux, SelectMonoAux,
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMonoSimple = (props: SubProps) => { export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>(
const downshiftReturn = useSelect({ (props, ref) => {
...props.downshiftProps, const downshiftReturn = useSelect({
items: props.options, ...props.downshiftProps,
itemToString: optionToString, items: props.options,
}); itemToString: optionToString,
});
// When component is controlled, this useEffect will update the local selected item. // When component is controlled, this useEffect will update the local selected item.
useEffect(() => { useEffect(() => {
const selectedItem = downshiftReturn.selectedItem const selectedItem = downshiftReturn.selectedItem
? optionToValue(downshiftReturn.selectedItem) ? optionToValue(downshiftReturn.selectedItem)
: undefined; : undefined;
const optionToSelect = props.options.find( const optionToSelect = props.options.find(
(option) => optionToValue(option) === props.value, (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>
);
};

View File

@@ -1,8 +1,8 @@
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { expect } from "vitest"; import { expect } from "vitest";
import React, { FormEvent, useState } from "react"; import React, { createRef, FormEvent, useState } from "react";
import { Select, Option } from ":/components/Forms/Select/index"; import { Select, Option, SelectHandle } from ":/components/Forms/Select/index";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { CunninghamProvider } from ":/components/Provider"; import { CunninghamProvider } from ":/components/Provider";
import { import {
@@ -787,6 +787,55 @@ describe("<Select/>", () => {
expect(input).toHaveValue("London"); 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", () => { describe("Simple", () => {
@@ -1547,5 +1596,53 @@ describe("<Select/>", () => {
screen.getByText("Value = |"); screen.getByText("Value = |");
screen.getByText("onChangeCounts = 2|"); 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("");
});
}); });
}); });

View File

@@ -1,11 +1,11 @@
import { Meta, StoryFn } from "@storybook/react"; 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 { useForm, FormProvider } from "react-hook-form";
import * as Yup from "yup"; import * as Yup from "yup";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; 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 { Button } from ":/components/Button";
import { RhfSelect } from ":/components/Forms/Select/stories-utils"; 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 = () => { export const FormExample = () => {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -1,9 +1,15 @@
import React, { PropsWithChildren, useEffect, useState } from "react"; import React, {
forwardRef,
PropsWithChildren,
useEffect,
useState,
} from "react";
import { UseSelectStateChange } from "downshift"; import { UseSelectStateChange } from "downshift";
import { FieldProps } from ":/components/Forms/Field"; import { FieldProps } from ":/components/Forms/Field";
import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common"; import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common";
import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable"; import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable";
import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple"; import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple";
import { SelectHandle } from ":/components/Forms/Select/index";
export interface Option { export interface Option {
value?: string; value?: string;
@@ -31,59 +37,63 @@ export type SelectProps = PropsWithChildren &
multi?: boolean; multi?: boolean;
}; };
export const SelectMono = (props: SelectProps) => { export const SelectMono = forwardRef<SelectHandle, SelectProps>(
const defaultSelectedItem = props.defaultValue (props, ref) => {
? props.options.find( const defaultSelectedItem = props.defaultValue
(option) => optionToValue(option) === props.defaultValue, ? props.options.find(
) (option) => optionToValue(option) === props.defaultValue,
: undefined; )
const [value, setValue] = useState( : undefined;
defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value, const [value, setValue] = useState(
); defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value,
);
/** /**
* This useEffect is used to update the local value when the component is controlled. * This useEffect is used to update the local value when the component is controlled.
* The defaultValue is used only on first render. * The defaultValue is used only on first render.
*/ */
useEffect(() => { useEffect(() => {
if (props.defaultValue) { if (props.defaultValue) {
return; 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,
},
});
} }
}, setValue(props.value);
isItemDisabled: (item) => !!item.disabled, }, [props.value, props.defaultValue]);
};
return props.searchable ? ( const commonDownshiftProps: SubProps["downshiftProps"] = {
<SelectMonoSearchable initialSelectedItem: defaultSelectedItem,
{...props} onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
downshiftProps={commonDownshiftProps} const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
value={value} 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
<SelectMonoSimple // set inside a useEffect down in SelectMonoSearchable or SelectMonoSimple. So that means the
{...props} // downshift component will always render empty the first time.
downshiftProps={commonDownshiftProps} if (eventCmp !== valueCmp) {
value={value} 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}
/>
);
},
);

View File

@@ -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 { useCombobox, useMultipleSelection } from "downshift";
import { optionToString } from ":/components/Forms/Select/mono-common"; import { optionToString } from ":/components/Forms/Select/mono-common";
import { import {
@@ -6,138 +12,152 @@ import {
SelectMultiAux, SelectMultiAux,
SubProps, SubProps,
} from ":/components/Forms/Select/multi-common"; } from ":/components/Forms/Select/multi-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMultiSearchable = (props: SubProps) => { export const SelectMultiSearchable = forwardRef<SelectHandle, SubProps>(
const [inputValue, setInputValue] = React.useState<string>(""); (props, ref) => {
const inputRef = useRef<HTMLInputElement>(null); const [inputValue, setInputValue] = React.useState<string>("");
const options = React.useMemo( const inputRef = useRef<HTMLInputElement>(null);
() => const options = React.useMemo(
props.options.filter( () =>
getMultiOptionsFilter(props.selectedItems, inputValue), props.options.filter(
), getMultiOptionsFilter(props.selectedItems, inputValue),
[props.selectedItems, inputValue], ),
); [props.selectedItems, inputValue],
const [hasInputFocused, setHasInputFocused] = useState(false); );
const useMultipleSelectionReturn = useMultipleSelection({ const [hasInputFocused, setHasInputFocused] = useState(false);
selectedItems: props.selectedItems, const useMultipleSelectionReturn = useMultipleSelection({
onStateChange({ selectedItems: newSelectedItems, type }) { selectedItems: props.selectedItems,
switch (type) { onStateChange({ selectedItems: newSelectedItems, type }) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: case useMultipleSelection.stateChangeTypes
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: .SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
props.onSelectedItemsChange(newSelectedItems ?? []); case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
break; case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
default: props.onSelectedItemsChange(newSelectedItems ?? []);
break; 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));
} }
} },
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); const inputProps = downshiftReturn.getInputProps({
useEffect(() => { ...useMultipleSelectionReturn.getDropdownProps({
if (hasInputFocused || inputValue) { preventKeyAction: downshiftReturn.isOpen,
setLabelAsPlaceholder(false); ref: inputRef,
return; disabled: props.disabled,
} }),
setLabelAsPlaceholder(props.selectedItems.length === 0); value: inputValue,
}, [props.selectedItems, hasInputFocused, 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 ( const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true);
<SelectMultiAux useEffect(() => {
{...props} if (hasInputFocused || inputValue) {
options={options} setLabelAsPlaceholder(false);
labelAsPlaceholder={labelAsPlaceholder} return;
selectedItems={props.selectedItems} }
downshiftReturn={{ setLabelAsPlaceholder(props.selectedItems.length === 0);
...downshiftReturn, }, [props.selectedItems, hasInputFocused, inputValue]);
wrapperProps: {
onClick: () => { useImperativeHandle(ref, () => ({
inputRef.current?.focus(); blur: () => {
downshiftReturn.openMenu(); 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(),
toggleButtonProps: downshiftReturn.getToggleButtonProps(), }}
}} useMultipleSelectionReturn={useMultipleSelectionReturn}
useMultipleSelectionReturn={useMultipleSelectionReturn} >
> <span
<span className="c__select__inner__value__input" data-value={inputValue}> className="c__select__inner__value__input"
<input data-value={inputValue}
{...inputProps} >
onFocus={() => { <input
setHasInputFocused(true); {...inputProps}
}} onFocus={() => {
onBlur={() => { setHasInputFocused(true);
setHasInputFocused(false); }}
}} onBlur={() => {
size={4} setHasInputFocused(false);
/> }}
</span> size={4}
</SelectMultiAux> />
); </span>
}; </SelectMultiAux>
);
},
);

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { useMultipleSelection, useSelect } from "downshift"; import { useMultipleSelection, useSelect } from "downshift";
import { import {
getMultiOptionsFilter, getMultiOptionsFilter,
@@ -6,95 +6,110 @@ import {
SubProps, SubProps,
} from ":/components/Forms/Select/multi-common"; } from ":/components/Forms/Select/multi-common";
import { optionToString } from ":/components/Forms/Select/mono-common"; import { optionToString } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMultiSimple = (props: SubProps) => { export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
const options = React.useMemo( (props, ref) => {
() => props.options.filter(getMultiOptionsFilter(props.selectedItems, "")), const options = React.useMemo(
[props.selectedItems], () =>
); props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
[props.selectedItems],
);
const useMultipleSelectionReturn = useMultipleSelection({ const useMultipleSelectionReturn = useMultipleSelection({
selectedItems: props.selectedItems, selectedItems: props.selectedItems,
onStateChange({ selectedItems: newSelectedItems, type }) { onStateChange({ selectedItems: newSelectedItems, type }) {
switch (type) { switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: case useMultipleSelection.stateChangeTypes
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: .SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
props.onSelectedItemsChange(newSelectedItems ?? []); case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
break; props.onSelectedItemsChange(newSelectedItems ?? []);
default: break;
break; default:
} break;
}, }
}); },
});
const downshiftReturn = useSelect({ const downshiftReturn = useSelect({
items: options, items: options,
itemToString: optionToString, itemToString: optionToString,
selectedItem: null, // Important, without this we are not able to re-select the last removed option. selectedItem: null, // Important, without this we are not able to re-select the last removed option.
defaultHighlightedIndex: 0, // after selection, highlight the first item. defaultHighlightedIndex: 0, // after selection, highlight the first item.
stateReducer: (state, actionAndChanges) => { stateReducer: (state, actionAndChanges) => {
const { changes, type } = actionAndChanges; const { changes, type } = actionAndChanges;
switch (type) { switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter: case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton: case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick: case useSelect.stateChangeTypes.ItemClick:
return { return {
...changes, ...changes,
isOpen: true, // keep the menu open after selection. isOpen: true, // keep the menu open after selection.
highlightedIndex: 0, // with the first option highlighted. highlightedIndex: 0, // with the first option highlighted.
}; };
} }
return changes; return changes;
}, },
onStateChange: ({ type, selectedItem: newSelectedItem }) => { onStateChange: ({ type, selectedItem: newSelectedItem }) => {
switch (type) { switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter: case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton: case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick: case useSelect.stateChangeTypes.ItemClick:
if (newSelectedItem) { if (newSelectedItem) {
props.onSelectedItemsChange([ props.onSelectedItemsChange([
...props.selectedItems, ...props.selectedItems,
newSelectedItem, 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();
} }
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({
toggleButtonProps: downshiftReturn.getToggleButtonProps({ ...useMultipleSelectionReturn.getDropdownProps({
...useMultipleSelectionReturn.getDropdownProps({ preventKeyAction: downshiftReturn.isOpen,
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 => { useMultipleSelectionReturn={useMultipleSelectionReturn}
// 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}
/>
);
};

View File

@@ -1,10 +1,10 @@
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/react"; 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 { expect } from "vitest";
import { within } from "@testing-library/dom"; import { within } from "@testing-library/dom";
import { CunninghamProvider } from ":/components/Provider"; import { CunninghamProvider } from ":/components/Provider";
import { Select } from ":/components/Forms/Select/index"; import { Select, SelectHandle } from ":/components/Forms/Select/index";
import { import {
expectMenuToBeClosed, expectMenuToBeClosed,
expectMenuToBeOpen, expectMenuToBeOpen,
@@ -785,6 +785,55 @@ describe("<Select multi={true} />", () => {
); );
expectSelectedOptions(["Paris"]); 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 () => { describe("Searchable", async () => {
@@ -1343,5 +1392,55 @@ describe("<Select multi={true} />", () => {
screen.getByText("No options available"); 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");
});
}); });
}); });

View File

@@ -1,11 +1,11 @@
import React, { useState } from "react"; import React, { useRef, useState } from "react";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import * as Yup from "yup"; import * as Yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Meta, StoryFn } from "@storybook/react"; import { Meta, StoryFn } from "@storybook/react";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; 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 { Button } from ":/components/Button";
import { RhfSelect } from ":/components/Forms/Select/stories-utils"; 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 = () => { export const FormExample = () => {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -1,58 +1,63 @@
import React, { useEffect } from "react"; import React, { forwardRef, useEffect } from "react";
import { optionToValue } from ":/components/Forms/Select/mono-common"; import { optionToValue } from ":/components/Forms/Select/mono-common";
import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable"; import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable";
import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple"; import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple";
import { SubProps } from ":/components/Forms/Select/multi-common"; import { SubProps } from ":/components/Forms/Select/multi-common";
import { Option, SelectProps } from ":/components/Forms/Select/mono"; import { Option, SelectProps } from ":/components/Forms/Select/mono";
import { SelectHandle } from ":/components/Forms/Select/index";
export type SelectMultiProps = Omit<SelectProps, "onChange"> & { export type SelectMultiProps = Omit<SelectProps, "onChange"> & {
onChange?: (event: { target: { value: string[] } }) => void; onChange?: (event: { target: { value: string[] } }) => void;
}; };
export const SelectMulti = (props: SelectMultiProps) => { export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>(
const getSelectedItemsFromProps = () => { (props, ref) => {
const valueToUse = props.defaultValue ?? props.value ?? []; const getSelectedItemsFromProps = () => {
return props.options.filter((option) => const valueToUse = props.defaultValue ?? props.value ?? [];
(valueToUse as string[]).includes(optionToValue(option)), return props.options.filter((option) =>
(valueToUse as string[]).includes(optionToValue(option)),
);
};
const [selectedItems, setSelectedItems] = React.useState<Option[]>(
getSelectedItemsFromProps(),
); );
};
const [selectedItems, setSelectedItems] = React.useState<Option[]>( // If the component is used as a controlled component, we need to update the local value when the value prop changes.
getSelectedItemsFromProps(), 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. // If the component is used as an uncontrolled component, we need to update the parent value when the local value changes.
useEffect(() => { useEffect(() => {
// Means it is not controlled. props.onChange?.({ target: { value: selectedItems.map(optionToValue) } });
if (props.defaultValue !== undefined) { }, [JSON.stringify(selectedItems)]);
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. const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = (
useEffect(() => { newSelectedItems,
props.onChange?.({ target: { value: selectedItems.map(optionToValue) } }); ) => {
}, [JSON.stringify(selectedItems)]); setSelectedItems(newSelectedItems);
// props.onSelectedItemsChange?.(newSelectedItems);
};
const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = ( return props.searchable ? (
newSelectedItems, <SelectMultiSearchable
) => { {...props}
setSelectedItems(newSelectedItems); selectedItems={selectedItems}
// props.onSelectedItemsChange?.(newSelectedItems); onSelectedItemsChange={onSelectedItemsChange}
}; ref={ref}
/>
return props.searchable ? ( ) : (
<SelectMultiSearchable <SelectMultiSimple
{...props} {...props}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectedItemsChange={onSelectedItemsChange} onSelectedItemsChange={onSelectedItemsChange}
/> ref={ref}
) : ( />
<SelectMultiSimple );
{...props} },
selectedItems={selectedItems} );
onSelectedItemsChange={onSelectedItemsChange}
/>
);
};