diff --git a/.changeset/twenty-bulldogs-whisper.md b/.changeset/twenty-bulldogs-whisper.md new file mode 100644 index 0000000..f23ec88 --- /dev/null +++ b/.changeset/twenty-bulldogs-whisper.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add ref to Select diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 4e1ac7a..b6b4335 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -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((props, ref) => { if (props.defaultValue && props.value) { throw new Error( "You cannot use both defaultValue and value props on Select component", ); } - return props.multi ? : ; -}; + return props.multi ? ( + + ) : ( + + ); +}); diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 865b550..5130e91 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { useCombobox } from "downshift"; import { useCunningham } from ":/components/Provider"; import { @@ -8,119 +14,129 @@ import { SelectMonoAux, SubProps, } from ":/components/Forms/Select/mono-common"; +import { SelectHandle } from ":/components/Forms/Select/index"; -export const SelectMonoSearchable = (props: SubProps) => { - const { t } = useCunningham(); - const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); - const [hasInputFocused, setHasInputFocused] = useState(false); - const [inputFilter, setInputFilter] = useState(); - const inputRef = useRef(null); - const downshiftReturn = useCombobox({ - ...props.downshiftProps, - items: optionsToDisplay, - itemToString: optionToString, - onInputValueChange: (e) => { - setInputFilter(e.inputValue); - if (!e.inputValue) { - downshiftReturn.selectItem(null); - } - }, - }); - const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( - !downshiftReturn.selectedItem, - ); - useEffect(() => { - if (hasInputFocused || downshiftReturn.inputValue) { - setLabelAsPlaceholder(false); - return; - } - setLabelAsPlaceholder(!downshiftReturn.selectedItem); - }, [ - downshiftReturn.selectedItem, - hasInputFocused, - downshiftReturn.inputValue, - ]); - - // When component is controlled, this useEffect will update the local selected item. - useEffect(() => { - if (inputFilter) { - return; - } - - const selectedItem = downshiftReturn.selectedItem - ? optionToValue(downshiftReturn.selectedItem) - : undefined; - - const optionToSelect = props.options.find( - (option) => optionToValue(option) === props.value, +export const SelectMonoSearchable = forwardRef( + (props, ref) => { + const { t } = useCunningham(); + const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); + const [hasInputFocused, setHasInputFocused] = useState(false); + const [inputFilter, setInputFilter] = useState(); + const inputRef = useRef(null); + const downshiftReturn = useCombobox({ + ...props.downshiftProps, + items: optionsToDisplay, + itemToString: optionToString, + onInputValueChange: (e) => { + setInputFilter(e.inputValue); + if (!e.inputValue) { + downshiftReturn.selectItem(null); + } + }, + }); + const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( + !downshiftReturn.selectedItem, ); + useEffect(() => { + if (hasInputFocused || downshiftReturn.inputValue) { + setLabelAsPlaceholder(false); + return; + } + setLabelAsPlaceholder(!downshiftReturn.selectedItem); + }, [ + downshiftReturn.selectedItem, + hasInputFocused, + downshiftReturn.inputValue, + ]); - // Already selected - if (optionToSelect && selectedItem === props.value) { - return; - } + // When component is controlled, this useEffect will update the local selected item. + useEffect(() => { + if (inputFilter) { + return; + } - downshiftReturn.selectItem(optionToSelect ?? null); - }, [props.value, props.options, inputFilter]); + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; - // Even there is already a value selected, when opening the combobox menu we want to display all available choices. - useEffect(() => { - if (downshiftReturn.isOpen) { - setOptionsToDisplay( - inputFilter - ? props.options.filter(getOptionsFilter(inputFilter)) - : props.options, + const optionToSelect = props.options.find( + (option) => optionToValue(option) === props.value, ); - } else { - setInputFilter(undefined); - } - }, [downshiftReturn.isOpen, props.options, inputFilter]); - const onInputBlur = () => { - setHasInputFocused(false); - if (downshiftReturn.selectedItem) { - // Here the goal is to make sure that when the input in blurred then the input value - // has exactly the selectedItem label. Which is not the case by default. - downshiftReturn.selectItem(downshiftReturn.selectedItem); - } else { - // We want the input to be empty when no item is selected. - downshiftReturn.setInputValue(""); - } - }; + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } - const inputProps = downshiftReturn.getInputProps({ - ref: inputRef, - disabled: props.disabled, - }); + downshiftReturn.selectItem(optionToSelect ?? null); + }, [props.value, props.options, inputFilter]); - return ( - { - inputRef.current?.focus(); - downshiftReturn.openMenu(); + // Even there is already a value selected, when opening the combobox menu we want to display all available choices. + useEffect(() => { + if (downshiftReturn.isOpen) { + setOptionsToDisplay( + inputFilter + ? props.options.filter(getOptionsFilter(inputFilter)) + : props.options, + ); + } else { + setInputFilter(undefined); + } + }, [downshiftReturn.isOpen, props.options, inputFilter]); + + useImperativeHandle(ref, () => ({ + blur: () => { + downshiftReturn.closeMenu(); + inputRef.current?.blur(); + }, + })); + + const onInputBlur = () => { + setHasInputFocused(false); + if (downshiftReturn.selectedItem) { + // Here the goal is to make sure that when the input in blurred then the input value + // has exactly the selectedItem label. Which is not the case by default. + downshiftReturn.selectItem(downshiftReturn.selectedItem); + } else { + // We want the input to be empty when no item is selected. + downshiftReturn.setInputValue(""); + } + }; + + const inputProps = downshiftReturn.getInputProps({ + ref: inputRef, + disabled: props.disabled, + }); + + return ( + { + inputRef.current?.focus(); + downshiftReturn.openMenu(); + }, }, - }, - toggleButtonProps: downshiftReturn.getToggleButtonProps({ - disabled: props.disabled, - "aria-label": t("components.forms.select.toggle_button_aria_label"), - }), - }} - labelAsPlaceholder={labelAsPlaceholder} - options={optionsToDisplay} - > - { - setHasInputFocused(true); + toggleButtonProps: downshiftReturn.getToggleButtonProps({ + disabled: props.disabled, + "aria-label": t("components.forms.select.toggle_button_aria_label"), + }), }} - onBlur={() => { - onInputBlur(); - }} - /> - - ); -}; + labelAsPlaceholder={labelAsPlaceholder} + options={optionsToDisplay} + > + { + setHasInputFocused(true); + }} + onBlur={() => { + onInputBlur(); + }} + /> + + ); + }, +); diff --git a/packages/react/src/components/Forms/Select/mono-simple.tsx b/packages/react/src/components/Forms/Select/mono-simple.tsx index 65cdbdc..2a808b7 100644 --- a/packages/react/src/components/Forms/Select/mono-simple.tsx +++ b/packages/react/src/components/Forms/Select/mono-simple.tsx @@ -1,52 +1,70 @@ import { useSelect } from "downshift"; -import React, { useEffect } from "react"; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from "react"; import { optionToString, optionToValue, SelectMonoAux, SubProps, } from ":/components/Forms/Select/mono-common"; +import { SelectHandle } from ":/components/Forms/Select/index"; -export const SelectMonoSimple = (props: SubProps) => { - const downshiftReturn = useSelect({ - ...props.downshiftProps, - items: props.options, - itemToString: optionToString, - }); +export const SelectMonoSimple = forwardRef( + (props, ref) => { + const downshiftReturn = useSelect({ + ...props.downshiftProps, + items: props.options, + itemToString: optionToString, + }); - // When component is controlled, this useEffect will update the local selected item. - useEffect(() => { - const selectedItem = downshiftReturn.selectedItem - ? optionToValue(downshiftReturn.selectedItem) - : undefined; + // When component is controlled, this useEffect will update the local selected item. + useEffect(() => { + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; - const optionToSelect = props.options.find( - (option) => optionToValue(option) === props.value, + const optionToSelect = props.options.find( + (option) => optionToValue(option) === props.value, + ); + + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } + + downshiftReturn.selectItem(optionToSelect ?? null); + }, [props.value, props.options]); + + const wrapperRef = useRef(null); + + useImperativeHandle(ref, () => ({ + blur: () => { + downshiftReturn.closeMenu(); + wrapperRef.current?.blur(); + }, + })); + + return ( + + {downshiftReturn.selectedItem && ( + {optionToString(downshiftReturn.selectedItem)} + )} + ); - - // Already selected - if (optionToSelect && selectedItem === props.value) { - return; - } - - downshiftReturn.selectItem(optionToSelect ?? null); - }, [props.value, props.options]); - - return ( - - {downshiftReturn.selectedItem && ( - {optionToString(downshiftReturn.selectedItem)} - )} - - ); -}; + }, +); diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 0562b9a..1d0a01d 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -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(" + , + ); + 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(" + , + ); + 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(""); + }); }); }); diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index 3021f81..409f9ae 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -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(null); + + return ( + <> +
+ +
+ + + ); +}; + export const FormExample = () => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/react/src/components/Forms/Select/mono.tsx b/packages/react/src/components/Forms/Select/mono.tsx index 50af340..10a7658 100644 --- a/packages/react/src/components/Forms/Select/mono.tsx +++ b/packages/react/src/components/Forms/Select/mono.tsx @@ -1,9 +1,15 @@ -import React, { PropsWithChildren, useEffect, useState } from "react"; +import React, { + forwardRef, + PropsWithChildren, + useEffect, + useState, +} from "react"; import { UseSelectStateChange } from "downshift"; import { FieldProps } from ":/components/Forms/Field"; import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common"; import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable"; import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple"; +import { SelectHandle } from ":/components/Forms/Select/index"; export interface Option { value?: string; @@ -31,59 +37,63 @@ export type SelectProps = PropsWithChildren & multi?: boolean; }; -export const SelectMono = (props: SelectProps) => { - const defaultSelectedItem = props.defaultValue - ? props.options.find( - (option) => optionToValue(option) === props.defaultValue, - ) - : undefined; - const [value, setValue] = useState( - defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value, - ); +export const SelectMono = forwardRef( + (props, ref) => { + const defaultSelectedItem = props.defaultValue + ? props.options.find( + (option) => optionToValue(option) === props.defaultValue, + ) + : undefined; + const [value, setValue] = useState( + defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value, + ); - /** - * This useEffect is used to update the local value when the component is controlled. - * The defaultValue is used only on first render. - */ - useEffect(() => { - if (props.defaultValue) { - return; - } - setValue(props.value); - }, [props.value, props.defaultValue]); - - const commonDownshiftProps: SubProps["downshiftProps"] = { - initialSelectedItem: defaultSelectedItem, - onSelectedItemChange: (e: UseSelectStateChange