(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 { 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} />
);
});

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 { useCunningham } from ":/components/Provider";
import {
@@ -8,8 +14,10 @@ import {
SelectMonoAux,
SubProps,
} from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMonoSearchable = (props: SubProps) => {
export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
(props, ref) => {
const { t } = useCunningham();
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
const [hasInputFocused, setHasInputFocused] = useState(false);
@@ -76,6 +84,13 @@ export const SelectMonoSearchable = (props: SubProps) => {
}
}, [downshiftReturn.isOpen, props.options, inputFilter]);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
inputRef.current?.blur();
},
}));
const onInputBlur = () => {
setHasInputFocused(false);
if (downshiftReturn.selectedItem) {
@@ -123,4 +138,5 @@ export const SelectMonoSearchable = (props: SubProps) => {
/>
</SelectMonoAux>
);
};
},
);

View File

@@ -1,13 +1,20 @@
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) => {
export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>(
(props, ref) => {
const downshiftReturn = useSelect({
...props.downshiftProps,
items: props.options,
@@ -32,6 +39,15 @@ export const SelectMonoSimple = (props: SubProps) => {
downshiftReturn.selectItem(optionToSelect ?? null);
}, [props.value, props.options]);
const wrapperRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
wrapperRef.current?.blur();
},
}));
return (
<SelectMonoAux
{...props}
@@ -39,6 +55,7 @@ export const SelectMonoSimple = (props: SubProps) => {
...downshiftReturn,
wrapperProps: downshiftReturn.getToggleButtonProps({
disabled: props.disabled,
ref: wrapperRef,
}),
toggleButtonProps: {},
}}
@@ -49,4 +66,5 @@ export const SelectMonoSimple = (props: SubProps) => {
)}
</SelectMonoAux>
);
};
},
);

View File

@@ -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("");
});
});
});

View File

@@ -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();

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 { 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,7 +37,8 @@ export type SelectProps = PropsWithChildren &
multi?: boolean;
};
export const SelectMono = (props: SelectProps) => {
export const SelectMono = forwardRef<SelectHandle, SelectProps>(
(props, ref) => {
const defaultSelectedItem = props.defaultValue
? props.options.find(
(option) => optionToValue(option) === props.defaultValue,
@@ -78,12 +85,15 @@ export const SelectMono = (props: SelectProps) => {
{...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 { optionToString } from ":/components/Forms/Select/mono-common";
import {
@@ -6,8 +12,10 @@ import {
SelectMultiAux,
SubProps,
} from ":/components/Forms/Select/multi-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMultiSearchable = (props: SubProps) => {
export const SelectMultiSearchable = forwardRef<SelectHandle, SubProps>(
(props, ref) => {
const [inputValue, setInputValue] = React.useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const options = React.useMemo(
@@ -22,7 +30,8 @@ export const SelectMultiSearchable = (props: SubProps) => {
selectedItems: props.selectedItems,
onStateChange({ selectedItems: newSelectedItems, type }) {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes
.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
@@ -108,6 +117,13 @@ export const SelectMultiSearchable = (props: SubProps) => {
setLabelAsPlaceholder(props.selectedItems.length === 0);
}, [props.selectedItems, hasInputFocused, inputValue]);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
inputRef.current?.blur();
},
}));
return (
<SelectMultiAux
{...props}
@@ -126,7 +142,10 @@ export const SelectMultiSearchable = (props: SubProps) => {
}}
useMultipleSelectionReturn={useMultipleSelectionReturn}
>
<span className="c__select__inner__value__input" data-value={inputValue}>
<span
className="c__select__inner__value__input"
data-value={inputValue}
>
<input
{...inputProps}
onFocus={() => {
@@ -140,4 +159,5 @@ export const SelectMultiSearchable = (props: SubProps) => {
</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 {
getMultiOptionsFilter,
@@ -6,10 +6,13 @@ 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) => {
export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
(props, ref) => {
const options = React.useMemo(
() => props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
() =>
props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
[props.selectedItems],
);
@@ -17,7 +20,8 @@ export const SelectMultiSimple = (props: SubProps) => {
selectedItems: props.selectedItems,
onStateChange({ selectedItems: newSelectedItems, type }) {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes
.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
@@ -67,6 +71,15 @@ export const SelectMultiSimple = (props: SubProps) => {
isItemDisabled: (item) => !!item.disabled,
});
const toggleRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
toggleRef.current?.blur();
},
}));
return (
<SelectMultiAux
{...props}
@@ -85,6 +98,7 @@ export const SelectMultiSimple = (props: SubProps) => {
toggleButtonProps: downshiftReturn.getToggleButtonProps({
...useMultipleSelectionReturn.getDropdownProps({
preventKeyAction: downshiftReturn.isOpen,
ref: toggleRef,
}),
disabled: props.disabled,
onClick: (e: React.MouseEvent): void => {
@@ -97,4 +111,5 @@ export const SelectMultiSimple = (props: SubProps) => {
useMultipleSelectionReturn={useMultipleSelectionReturn}
/>
);
};
},
);

View File

@@ -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");
});
});
});

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 * 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();

View File

@@ -1,15 +1,17 @@
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) => {
export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>(
(props, ref) => {
const getSelectedItemsFromProps = () => {
const valueToUse = props.defaultValue ?? props.value ?? [];
return props.options.filter((option) =>
@@ -47,12 +49,15 @@ export const SelectMulti = (props: SelectMultiProps) => {
{...props}
selectedItems={selectedItems}
onSelectedItemsChange={onSelectedItemsChange}
ref={ref}
/>
) : (
<SelectMultiSimple
{...props}
selectedItems={selectedItems}
onSelectedItemsChange={onSelectedItemsChange}
ref={ref}
/>
);
};
},
);