✨(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 { 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} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,8 +14,10 @@ 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>(
|
||||||
|
(props, ref) => {
|
||||||
const { t } = useCunningham();
|
const { t } = useCunningham();
|
||||||
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
||||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||||
@@ -76,6 +84,13 @@ export const SelectMonoSearchable = (props: SubProps) => {
|
|||||||
}
|
}
|
||||||
}, [downshiftReturn.isOpen, props.options, inputFilter]);
|
}, [downshiftReturn.isOpen, props.options, inputFilter]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
blur: () => {
|
||||||
|
downshiftReturn.closeMenu();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const onInputBlur = () => {
|
const onInputBlur = () => {
|
||||||
setHasInputFocused(false);
|
setHasInputFocused(false);
|
||||||
if (downshiftReturn.selectedItem) {
|
if (downshiftReturn.selectedItem) {
|
||||||
@@ -123,4 +138,5 @@ export const SelectMonoSearchable = (props: SubProps) => {
|
|||||||
/>
|
/>
|
||||||
</SelectMonoAux>
|
</SelectMonoAux>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
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>(
|
||||||
|
(props, ref) => {
|
||||||
const downshiftReturn = useSelect({
|
const downshiftReturn = useSelect({
|
||||||
...props.downshiftProps,
|
...props.downshiftProps,
|
||||||
items: props.options,
|
items: props.options,
|
||||||
@@ -32,6 +39,15 @@ export const SelectMonoSimple = (props: SubProps) => {
|
|||||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||||
}, [props.value, props.options]);
|
}, [props.value, props.options]);
|
||||||
|
|
||||||
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
blur: () => {
|
||||||
|
downshiftReturn.closeMenu();
|
||||||
|
wrapperRef.current?.blur();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectMonoAux
|
<SelectMonoAux
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,6 +55,7 @@ export const SelectMonoSimple = (props: SubProps) => {
|
|||||||
...downshiftReturn,
|
...downshiftReturn,
|
||||||
wrapperProps: downshiftReturn.getToggleButtonProps({
|
wrapperProps: downshiftReturn.getToggleButtonProps({
|
||||||
disabled: props.disabled,
|
disabled: props.disabled,
|
||||||
|
ref: wrapperRef,
|
||||||
}),
|
}),
|
||||||
toggleButtonProps: {},
|
toggleButtonProps: {},
|
||||||
}}
|
}}
|
||||||
@@ -49,4 +66,5 @@ export const SelectMonoSimple = (props: SubProps) => {
|
|||||||
)}
|
)}
|
||||||
</SelectMonoAux>
|
</SelectMonoAux>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,7 +37,8 @@ export type SelectProps = PropsWithChildren &
|
|||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectMono = (props: SelectProps) => {
|
export const SelectMono = forwardRef<SelectHandle, SelectProps>(
|
||||||
|
(props, ref) => {
|
||||||
const defaultSelectedItem = props.defaultValue
|
const defaultSelectedItem = props.defaultValue
|
||||||
? props.options.find(
|
? props.options.find(
|
||||||
(option) => optionToValue(option) === props.defaultValue,
|
(option) => optionToValue(option) === props.defaultValue,
|
||||||
@@ -78,12 +85,15 @@ export const SelectMono = (props: SelectProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
downshiftProps={commonDownshiftProps}
|
downshiftProps={commonDownshiftProps}
|
||||||
value={value}
|
value={value}
|
||||||
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SelectMonoSimple
|
<SelectMonoSimple
|
||||||
{...props}
|
{...props}
|
||||||
downshiftProps={commonDownshiftProps}
|
downshiftProps={commonDownshiftProps}
|
||||||
value={value}
|
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 { useCombobox, useMultipleSelection } from "downshift";
|
||||||
import { optionToString } from ":/components/Forms/Select/mono-common";
|
import { optionToString } from ":/components/Forms/Select/mono-common";
|
||||||
import {
|
import {
|
||||||
@@ -6,8 +12,10 @@ 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>(
|
||||||
|
(props, ref) => {
|
||||||
const [inputValue, setInputValue] = React.useState<string>("");
|
const [inputValue, setInputValue] = React.useState<string>("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const options = React.useMemo(
|
const options = React.useMemo(
|
||||||
@@ -22,7 +30,8 @@ export const SelectMultiSearchable = (props: SubProps) => {
|
|||||||
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
|
||||||
|
.SelectedItemKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
@@ -108,6 +117,13 @@ export const SelectMultiSearchable = (props: SubProps) => {
|
|||||||
setLabelAsPlaceholder(props.selectedItems.length === 0);
|
setLabelAsPlaceholder(props.selectedItems.length === 0);
|
||||||
}, [props.selectedItems, hasInputFocused, inputValue]);
|
}, [props.selectedItems, hasInputFocused, inputValue]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
blur: () => {
|
||||||
|
downshiftReturn.closeMenu();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectMultiAux
|
<SelectMultiAux
|
||||||
{...props}
|
{...props}
|
||||||
@@ -126,7 +142,10 @@ export const SelectMultiSearchable = (props: SubProps) => {
|
|||||||
}}
|
}}
|
||||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||||
>
|
>
|
||||||
<span className="c__select__inner__value__input" data-value={inputValue}>
|
<span
|
||||||
|
className="c__select__inner__value__input"
|
||||||
|
data-value={inputValue}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@@ -140,4 +159,5 @@ export const SelectMultiSearchable = (props: SubProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</SelectMultiAux>
|
</SelectMultiAux>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,10 +6,13 @@ 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>(
|
||||||
|
(props, ref) => {
|
||||||
const options = React.useMemo(
|
const options = React.useMemo(
|
||||||
() => props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
|
() =>
|
||||||
|
props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
|
||||||
[props.selectedItems],
|
[props.selectedItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ export const SelectMultiSimple = (props: SubProps) => {
|
|||||||
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
|
||||||
|
.SelectedItemKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
@@ -67,6 +71,15 @@ export const SelectMultiSimple = (props: SubProps) => {
|
|||||||
isItemDisabled: (item) => !!item.disabled,
|
isItemDisabled: (item) => !!item.disabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
blur: () => {
|
||||||
|
downshiftReturn.closeMenu();
|
||||||
|
toggleRef.current?.blur();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectMultiAux
|
<SelectMultiAux
|
||||||
{...props}
|
{...props}
|
||||||
@@ -85,6 +98,7 @@ export const SelectMultiSimple = (props: SubProps) => {
|
|||||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||||
...useMultipleSelectionReturn.getDropdownProps({
|
...useMultipleSelectionReturn.getDropdownProps({
|
||||||
preventKeyAction: downshiftReturn.isOpen,
|
preventKeyAction: downshiftReturn.isOpen,
|
||||||
|
ref: toggleRef,
|
||||||
}),
|
}),
|
||||||
disabled: props.disabled,
|
disabled: props.disabled,
|
||||||
onClick: (e: React.MouseEvent): void => {
|
onClick: (e: React.MouseEvent): void => {
|
||||||
@@ -97,4 +111,5 @@ export const SelectMultiSimple = (props: SubProps) => {
|
|||||||
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
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>(
|
||||||
|
(props, ref) => {
|
||||||
const getSelectedItemsFromProps = () => {
|
const getSelectedItemsFromProps = () => {
|
||||||
const valueToUse = props.defaultValue ?? props.value ?? [];
|
const valueToUse = props.defaultValue ?? props.value ?? [];
|
||||||
return props.options.filter((option) =>
|
return props.options.filter((option) =>
|
||||||
@@ -47,12 +49,15 @@ export const SelectMulti = (props: SelectMultiProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectedItemsChange={onSelectedItemsChange}
|
onSelectedItemsChange={onSelectedItemsChange}
|
||||||
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SelectMultiSimple
|
<SelectMultiSimple
|
||||||
{...props}
|
{...props}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectedItemsChange={onSelectedItemsChange}
|
onSelectedItemsChange={onSelectedItemsChange}
|
||||||
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user