From 48e4e56a448127d2207229f8814306e2d58d4b1d Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Fri, 6 Oct 2023 16:54:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20select=20mono=20option?= =?UTF-8?q?=20custom=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to be able to render the options in a customized manner. --- .../src/components/Forms/Select/index.scss | 8 + .../src/components/Forms/Select/index.tsx | 21 +- .../components/Forms/Select/mono-common.tsx | 20 +- .../Forms/Select/mono-searchable.tsx | 16 +- .../components/Forms/Select/mono-simple.tsx | 7 +- .../src/components/Forms/Select/mono.mdx | 13 ++ .../src/components/Forms/Select/mono.spec.tsx | 211 +++++++++++++++++- .../components/Forms/Select/mono.stories.tsx | 36 ++- .../src/components/Forms/Select/mono.tsx | 8 +- .../components/Forms/Select/stories-utils.tsx | 15 ++ .../src/components/Forms/Select/utils.tsx | 24 ++ 11 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 packages/react/src/components/Forms/Select/utils.tsx diff --git a/packages/react/src/components/Forms/Select/index.scss b/packages/react/src/components/Forms/Select/index.scss index 24b88ce..e205f96 100644 --- a/packages/react/src/components/Forms/Select/index.scss +++ b/packages/react/src/components/Forms/Select/index.scss @@ -62,6 +62,14 @@ font-size: var(--c--components--forms-select--font-size); background-color: var(--c--components--forms-select--background-color); } + + &__input { + &--hidden { + // Using display: none makes impossible to focus the input. + position: absolute; + height: 0; + } + } } &__actions { diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index f33e2aa..56dbb04 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -1,11 +1,27 @@ -import React, { forwardRef, PropsWithChildren } from "react"; +import React, { forwardRef, PropsWithChildren, ReactNode } from "react"; import { SelectMulti } from ":/components/Forms/Select/multi"; -import { Option, SelectMono } from ":/components/Forms/Select/mono"; +import { SelectMono } from ":/components/Forms/Select/mono"; import { FieldProps } from ":/components/Forms/Field"; export * from ":/components/Forms/Select/mono"; export * from ":/components/Forms/Select/multi"; +export type OptionWithRender = { + disabled?: boolean; + value: string; + label: string; + render: () => ReactNode; +}; + +export type Option = + | { + disabled?: boolean; + value?: string; + label: string; + render?: undefined; + } + | OptionWithRender; + export interface SelectHandle { blur: () => void; } @@ -28,6 +44,7 @@ export type SelectProps = PropsWithChildren & disabled?: boolean; clearable?: boolean; multi?: boolean; + showLabelWhenSelected?: boolean; }; export const Select = forwardRef((props, ref) => { if (props.defaultValue && props.value) { diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index b1f3a77..ee2848a 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -5,8 +5,8 @@ import { useCunningham } from ":/components/Provider"; import { Field } from ":/components/Forms/Field"; import { LabelledBox } from ":/components/Forms/LabelledBox"; import { Button } from ":/components/Button"; -import { Option } from ":/components/Forms/Select/mono"; -import { SelectProps } from ":/components/Forms/Select"; +import { Option, SelectProps } from ":/components/Forms/Select"; +import { isOptionWithRender } from ":/components/Forms/Select/utils"; export function getOptionsFilter(inputValue?: string) { return (option: Option) => { @@ -22,10 +22,20 @@ export const optionToString = (option: Option | null) => { return option ? option.label : ""; }; +/** + * Returns underlying value of option. + */ export const optionToValue = (option: Option) => { return option.value ?? option.label; }; +export const renderOption = (option: Option) => { + if (isOptionWithRender(option)) { + return option.render(); + } + return option.label; +}; + export interface SubProps extends SelectProps { defaultSelectedItem?: Option; downshiftProps: { @@ -159,12 +169,12 @@ export const SelectMonoAux = ({
    - {downshiftReturn.isOpen && ( + {(downshiftReturn.isOpen || false) && ( <> {options.map((item, index) => { const isActive = index === downshiftReturn.highlightedIndex; @@ -182,7 +192,7 @@ export const SelectMonoAux = ({ index, })} > - {item.label} + {renderOption(item)} ); })} diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index e392194..ff894ae 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -6,6 +6,7 @@ import React, { useState, } from "react"; import { useCombobox } from "downshift"; +import classNames from "classnames"; import { useCunningham } from ":/components/Provider"; import { getOptionsFilter, @@ -15,9 +16,10 @@ import { SubProps, } from ":/components/Forms/Select/mono-common"; import { SelectHandle } from ":/components/Forms/Select"; +import { isOptionWithRender } from ":/components/Forms/Select/utils"; export const SelectMonoSearchable = forwardRef( - (props, ref) => { + ({ showLabelWhenSelected = true, ...props }, ref) => { const { t } = useCunningham(); const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); const [hasInputFocused, setHasInputFocused] = useState(false); @@ -108,6 +110,8 @@ export const SelectMonoSearchable = forwardRef( disabled: props.disabled, }); + const renderCustomSelectedOption = !showLabelWhenSelected; + return ( ( > { setHasInputFocused(true); }} @@ -136,6 +144,12 @@ export const SelectMonoSearchable = forwardRef( onInputBlur(); }} /> + + {renderCustomSelectedOption && + !hasInputFocused && + downshiftReturn.selectedItem && + isOptionWithRender(downshiftReturn.selectedItem) && + downshiftReturn.selectedItem.render()} ); }, diff --git a/packages/react/src/components/Forms/Select/mono-simple.tsx b/packages/react/src/components/Forms/Select/mono-simple.tsx index 2a808b7..8ed0bf0 100644 --- a/packages/react/src/components/Forms/Select/mono-simple.tsx +++ b/packages/react/src/components/Forms/Select/mono-simple.tsx @@ -11,7 +11,8 @@ import { SelectMonoAux, SubProps, } from ":/components/Forms/Select/mono-common"; -import { SelectHandle } from ":/components/Forms/Select/index"; +import { SelectHandle } from ":/components/Forms/Select"; +import { SelectedOption } from ":/components/Forms/Select/utils"; export const SelectMonoSimple = forwardRef( (props, ref) => { @@ -61,9 +62,7 @@ export const SelectMonoSimple = forwardRef( }} labelAsPlaceholder={!downshiftReturn.selectedItem} > - {downshiftReturn.selectedItem && ( - {optionToString(downshiftReturn.selectedItem)} - )} + ); }, diff --git a/packages/react/src/components/Forms/Select/mono.mdx b/packages/react/src/components/Forms/Select/mono.mdx index 6e1d66f..c68d7c2 100644 --- a/packages/react/src/components/Forms/Select/mono.mdx +++ b/packages/react/src/components/Forms/Select/mono.mdx @@ -103,6 +103,19 @@ For some reasons you might want to hide the label of the select. You can do that +## Custom render option + +You can give customize the look of the options by providing `render` callback. + +> When you provide `render` the fields `label` and `value` are mandatory. + +Feel free to use the attribute `showLabelWhenSelected` to choose whether you want to display selected option with the custom +HTML or with its `label`. It is set to `true` by default. + + + + + ## Controlled / Non Controlled Like a native select, you can use the Select component in a controlled or non controlled way. You can see the example below diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 80966a2..a04eaa4 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -2,7 +2,13 @@ import userEvent from "@testing-library/user-event"; import { render, screen, waitFor } from "@testing-library/react"; import { expect } from "vitest"; import React, { createRef, FormEvent, useState } from "react"; -import { Select, Option, SelectHandle } from ":/components/Forms/Select/index"; +import { within } from "@testing-library/dom"; +import { + Select, + Option, + SelectHandle, + SelectProps, +} from ":/components/Forms/Select/index"; import { Button } from ":/components/Button"; import { CunninghamProvider } from ":/components/Provider"; import { @@ -836,6 +842,123 @@ describe(" + + + ); + }; + + const props: SelectProps = { + label: "City", + searchable: true, + options: [ + { + label: "Paris", + value: "paris", + render: () => ( +
    + Paris flag + Paris +
    + ), + }, + { + label: "Panama", + value: "panama", + render: () => ( +
    + Panama flag + Panama +
    + ), + }, + { + label: "London", + value: "london", + render: () => ( +
    + London flag + London +
    + ), + }, + ], + }; + + const { rerender } = render(); + const input = screen.getByRole("combobox", { + name: "City", + }); + const menu: HTMLDivElement = screen.getByRole("listbox", { + name: "City", + }); + const blurButton = screen.getByRole("button", { name: "Blur" }); + const user = userEvent.setup(); + const valueRendered = document.querySelector( + ".c__select__inner__value", + ) as HTMLElement; + + await user.click(input); + expectMenuToBeOpen(menu); + screen.getByRole("img", { name: "Paris flag" }); + screen.getByRole("img", { name: "Panama flag" }); + screen.getByRole("img", { name: "London flag" }); + + await user.type(input, "Pa"); + screen.getByRole("img", { name: "Paris flag" }); + screen.getByRole("img", { name: "Panama flag" }); + expect( + screen.queryByRole("img", { name: "London flag" }), + ).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("option", { name: "Paris flag Paris" }), + ); + await user.click(blurButton); + + // Make sure only the label is rendered by default. + expect(input).toHaveValue("Paris"); + expect(input).not.toHaveClass("c__select__inner__value__input--hidden"); + expect( + within(valueRendered).queryByRole("img", { + name: "Paris flag", + }), + ).not.toBeInTheDocument(); + + // Now showLabelWhenSelected to false. + rerender(); + + // Make sure the HTML content of the option is rendered. + // The input is still present in the DOM ( but hidden for users ). + expect(input).toHaveValue("Paris"); + expect(input).toHaveClass("c__select__inner__value__input--hidden"); + within(valueRendered).getByRole("img", { + name: "Paris flag", + }); + + // Focus on the input and make sure the custom HTML is removed. + await user.click(input); + expect(input).toHaveValue("Paris"); + expect(input).not.toHaveClass("c__select__inner__value__input--hidden"); + expect( + within(valueRendered).queryByRole("img", { + name: "Paris flag", + }), + ).not.toBeInTheDocument(); + + // Blur the input and make sure the custom HTML is rendered. + await user.click(blurButton); + expect(input).toHaveValue("Paris"); + expect(input).toHaveClass("c__select__inner__value__input--hidden"); + within(valueRendered).getByRole("img", { + name: "Paris flag", + }); + }); }); describe("Simple", () => { @@ -1644,5 +1767,91 @@ describe(" + + ); + }; + + const props: SelectProps = { + label: "City", + options: [ + { + label: "Paris", + value: "paris", + render: () => ( +
    + Paris flag + Paris +
    + ), + }, + { + label: "Panama", + value: "panama", + render: () => ( +
    + Panama flag + Panama +
    + ), + }, + { + label: "London", + value: "london", + render: () => ( +
    + London flag + London +
    + ), + }, + ], + }; + + const { rerender } = render(); + const input = screen.getByRole("combobox", { + name: "City", + }); + const menu: HTMLDivElement = screen.getByRole("listbox", { + name: "City", + }); + const user = userEvent.setup(); + const valueRendered = document.querySelector( + ".c__select__inner__value", + ) as HTMLElement; + + await user.click(input); + expectMenuToBeOpen(menu); + + screen.getByRole("img", { name: "Paris flag" }); + screen.getByRole("img", { name: "Panama flag" }); + screen.getByRole("img", { name: "London flag" }); + + await user.click( + screen.getByRole("option", { name: "London flag London" }), + ); + + // Make sure only the label is rendered by default. + expect(valueRendered).toHaveTextContent("London"); + expect( + within(valueRendered).queryByRole("img", { + name: "London flag", + }), + ).not.toBeInTheDocument(); + + // Now showLabelWhenSelected to false. + rerender(); + + // Make sure the HTML content of the option is rendered. + expect(valueRendered).toHaveTextContent("London"); + within(valueRendered).getByRole("img", { + name: "London flag", + }); + }); }); }); diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index 409f9ae..fd5039e 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -7,7 +7,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; import { Select, SelectHandle } from ":/components/Forms/Select"; import { Button } from ":/components/Button"; -import { RhfSelect } from ":/components/Forms/Select/stories-utils"; +import { + getCountryOption, + RhfSelect, +} from ":/components/Forms/Select/stories-utils"; export default { title: "Components/Forms/Select/Mono", @@ -228,6 +231,37 @@ export const NoOptions = { }, }; +export const CustomRender = { + render: Template, + args: { + label: "Select a country", + showLabelWhenSelected: false, + options: [ + getCountryOption("Germany", "DE"), + getCountryOption("France", "FR"), + getCountryOption("United States", "US"), + getCountryOption("Spain", "ES"), + getCountryOption("China", "CN"), + ], + defaultValue: "france", + }, +}; +export const SearchableCustomRender = { + render: Template, + args: { + label: "Select a country", + showLabelWhenSelected: false, + searchable: true, + options: [ + getCountryOption("Germany", "DE"), + getCountryOption("France", "FR"), + getCountryOption("United States", "US"), + getCountryOption("Spain", "ES"), + getCountryOption("China", "CN"), + ], + }, +}; + export const Ref = () => { const ref = useRef(null); diff --git a/packages/react/src/components/Forms/Select/mono.tsx b/packages/react/src/components/Forms/Select/mono.tsx index 1128173..f364b81 100644 --- a/packages/react/src/components/Forms/Select/mono.tsx +++ b/packages/react/src/components/Forms/Select/mono.tsx @@ -3,13 +3,7 @@ import { UseSelectStateChange } from "downshift"; 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, SelectProps } from ":/components/Forms/Select"; - -export interface Option { - value?: string; - label: string; - disabled?: boolean; -} +import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select"; export const SelectMono = forwardRef( (props, ref) => { diff --git a/packages/react/src/components/Forms/Select/stories-utils.tsx b/packages/react/src/components/Forms/Select/stories-utils.tsx index bda9646..f4c50b2 100644 --- a/packages/react/src/components/Forms/Select/stories-utils.tsx +++ b/packages/react/src/components/Forms/Select/stories-utils.tsx @@ -23,3 +23,18 @@ export const RhfSelect = (props: SelectProps & { name: string }) => { /> ); }; + +export const getCountryOption = (name: string, code: string) => ({ + value: name.toLowerCase(), + label: name, + render: () => ( +
    + Flag + {name} +
    + ), +}); diff --git a/packages/react/src/components/Forms/Select/utils.tsx b/packages/react/src/components/Forms/Select/utils.tsx new file mode 100644 index 0000000..eb69510 --- /dev/null +++ b/packages/react/src/components/Forms/Select/utils.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Option, OptionWithRender } from ":/components/Forms/Select"; + +export const isOptionWithRender = ( + option: Option, +): option is OptionWithRender => { + return (option as OptionWithRender).render !== undefined; +}; + +export const SelectedOption = ({ + option, + showLabelWhenSelected = true, +}: { + option: Option | undefined | null; + showLabelWhenSelected?: boolean; +}) => { + if (!option) { + return null; + } + if (isOptionWithRender(option) && !showLabelWhenSelected) { + return option.render(); + } + return {option.label}; +};