(react) add select mono option custom render

We want to be able to render the options in a customized manner.
This commit is contained in:
Nathan Vasse
2023-10-06 16:54:55 +02:00
committed by NathanVss
parent 611eebf0a4
commit 48e4e56a44
11 changed files with 358 additions and 21 deletions

View File

@@ -62,6 +62,14 @@
font-size: var(--c--components--forms-select--font-size); font-size: var(--c--components--forms-select--font-size);
background-color: var(--c--components--forms-select--background-color); 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 { &__actions {

View File

@@ -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 { 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"; import { FieldProps } from ":/components/Forms/Field";
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 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 { export interface SelectHandle {
blur: () => void; blur: () => void;
} }
@@ -28,6 +44,7 @@ export type SelectProps = PropsWithChildren &
disabled?: boolean; disabled?: boolean;
clearable?: boolean; clearable?: boolean;
multi?: boolean; multi?: boolean;
showLabelWhenSelected?: boolean;
}; };
export const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => { export const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => {
if (props.defaultValue && props.value) { if (props.defaultValue && props.value) {

View File

@@ -5,8 +5,8 @@ import { useCunningham } from ":/components/Provider";
import { Field } from ":/components/Forms/Field"; import { Field } from ":/components/Forms/Field";
import { LabelledBox } from ":/components/Forms/LabelledBox"; import { LabelledBox } from ":/components/Forms/LabelledBox";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { Option } from ":/components/Forms/Select/mono"; import { Option, SelectProps } from ":/components/Forms/Select";
import { SelectProps } from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils";
export function getOptionsFilter(inputValue?: string) { export function getOptionsFilter(inputValue?: string) {
return (option: Option) => { return (option: Option) => {
@@ -22,10 +22,20 @@ export const optionToString = (option: Option | null) => {
return option ? option.label : ""; return option ? option.label : "";
}; };
/**
* Returns underlying value of option.
*/
export const optionToValue = (option: Option) => { export const optionToValue = (option: Option) => {
return option.value ?? option.label; return option.value ?? option.label;
}; };
export const renderOption = (option: Option) => {
if (isOptionWithRender(option)) {
return option.render();
}
return option.label;
};
export interface SubProps extends SelectProps { export interface SubProps extends SelectProps {
defaultSelectedItem?: Option; defaultSelectedItem?: Option;
downshiftProps: { downshiftProps: {
@@ -159,12 +169,12 @@ export const SelectMonoAux = ({
</div> </div>
<div <div
className={classNames("c__select__menu", { className={classNames("c__select__menu", {
"c__select__menu--opened": downshiftReturn.isOpen, "c__select__menu--opened": downshiftReturn.isOpen || false,
})} })}
{...downshiftReturn.getMenuProps()} {...downshiftReturn.getMenuProps()}
> >
<ul> <ul>
{downshiftReturn.isOpen && ( {(downshiftReturn.isOpen || false) && (
<> <>
{options.map((item, index) => { {options.map((item, index) => {
const isActive = index === downshiftReturn.highlightedIndex; const isActive = index === downshiftReturn.highlightedIndex;
@@ -182,7 +192,7 @@ export const SelectMonoAux = ({
index, index,
})} })}
> >
<span>{item.label}</span> <span>{renderOption(item)}</span>
</li> </li>
); );
})} })}

View File

@@ -6,6 +6,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import classNames from "classnames";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
import { import {
getOptionsFilter, getOptionsFilter,
@@ -15,9 +16,10 @@ import {
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select"; import { SelectHandle } from ":/components/Forms/Select";
import { isOptionWithRender } from ":/components/Forms/Select/utils";
export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>( export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
(props, ref) => { ({ showLabelWhenSelected = true, ...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);
@@ -108,6 +110,8 @@ export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
disabled: props.disabled, disabled: props.disabled,
}); });
const renderCustomSelectedOption = !showLabelWhenSelected;
return ( return (
<SelectMonoAux <SelectMonoAux
{...props} {...props}
@@ -129,6 +133,10 @@ export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
> >
<input <input
{...inputProps} {...inputProps}
className={classNames({
"c__select__inner__value__input--hidden":
renderCustomSelectedOption && !hasInputFocused,
})}
onFocus={() => { onFocus={() => {
setHasInputFocused(true); setHasInputFocused(true);
}} }}
@@ -136,6 +144,12 @@ export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>(
onInputBlur(); onInputBlur();
}} }}
/> />
{renderCustomSelectedOption &&
!hasInputFocused &&
downshiftReturn.selectedItem &&
isOptionWithRender(downshiftReturn.selectedItem) &&
downshiftReturn.selectedItem.render()}
</SelectMonoAux> </SelectMonoAux>
); );
}, },

View File

@@ -11,7 +11,8 @@ import {
SelectMonoAux, SelectMonoAux,
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } 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<SelectHandle, SubProps>( export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>(
(props, ref) => { (props, ref) => {
@@ -61,9 +62,7 @@ export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>(
}} }}
labelAsPlaceholder={!downshiftReturn.selectedItem} labelAsPlaceholder={!downshiftReturn.selectedItem}
> >
{downshiftReturn.selectedItem && ( <SelectedOption option={downshiftReturn.selectedItem} {...props} />
<span>{optionToString(downshiftReturn.selectedItem)}</span>
)}
</SelectMonoAux> </SelectMonoAux>
); );
}, },

View File

@@ -103,6 +103,19 @@ For some reasons you might want to hide the label of the select. You can do that
<Story id="components-forms-select-mono--hidden-label"/> <Story id="components-forms-select-mono--hidden-label"/>
</Canvas> </Canvas>
## 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.
<Canvas sourceState="shown">
<Story id="components-forms-select-mono--custom-render"/>
</Canvas>
## Controlled / Non Controlled ## 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 Like a native select, you can use the Select component in a controlled or non controlled way. You can see the example below

View File

@@ -2,7 +2,13 @@ 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, { createRef, FormEvent, useState } from "react"; 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 { Button } from ":/components/Button";
import { CunninghamProvider } from ":/components/Provider"; import { CunninghamProvider } from ":/components/Provider";
import { import {
@@ -836,6 +842,123 @@ describe("<Select/>", () => {
await waitFor(() => expectMenuToBeClosed(menu)); await waitFor(() => expectMenuToBeClosed(menu));
expect(document.activeElement?.tagName).toEqual("BODY"); expect(document.activeElement?.tagName).toEqual("BODY");
}); });
it("renders custom options", async () => {
const Wrapper = (props: SelectProps) => {
return (
<CunninghamProvider>
<Select {...props} />
<Button>Blur</Button>
</CunninghamProvider>
);
};
const props: SelectProps = {
label: "City",
searchable: true,
options: [
{
label: "Paris",
value: "paris",
render: () => (
<div>
<img src="paris.png" alt="Paris flag" />
Paris
</div>
),
},
{
label: "Panama",
value: "panama",
render: () => (
<div>
<img src="panama.png" alt="Panama flag" />
Panama
</div>
),
},
{
label: "London",
value: "london",
render: () => (
<div>
<img src="london.png" alt="London flag" />
London
</div>
),
},
],
};
const { rerender } = render(<Wrapper {...props} />);
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(<Wrapper {...props} showLabelWhenSelected={false} />);
// 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", () => { describe("Simple", () => {
@@ -1644,5 +1767,91 @@ describe("<Select/>", () => {
await waitFor(() => expectMenuToBeClosed(menu)); await waitFor(() => expectMenuToBeClosed(menu));
expect(document.activeElement?.className).toEqual(""); expect(document.activeElement?.className).toEqual("");
}); });
it("renders custom options", async () => {
const Wrapper = (props: SelectProps) => {
return (
<CunninghamProvider>
<Select {...props} />
</CunninghamProvider>
);
};
const props: SelectProps = {
label: "City",
options: [
{
label: "Paris",
value: "paris",
render: () => (
<div>
<img src="paris.png" alt="Paris flag" />
Paris
</div>
),
},
{
label: "Panama",
value: "panama",
render: () => (
<div>
<img src="panama.png" alt="Panama flag" />
Panama
</div>
),
},
{
label: "London",
value: "london",
render: () => (
<div>
<img src="london.png" alt="London flag" />
London
</div>
),
},
],
};
const { rerender } = render(<Wrapper {...props} />);
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(<Wrapper {...props} showLabelWhenSelected={false} />);
// Make sure the HTML content of the option is rendered.
expect(valueRendered).toHaveTextContent("London");
within(valueRendered).getByRole("img", {
name: "London flag",
});
});
}); });
}); });

View File

@@ -7,7 +7,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils";
import { Select, SelectHandle } 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 {
getCountryOption,
RhfSelect,
} from ":/components/Forms/Select/stories-utils";
export default { export default {
title: "Components/Forms/Select/Mono", 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 = () => { export const Ref = () => {
const ref = useRef<SelectHandle>(null); const ref = useRef<SelectHandle>(null);

View File

@@ -3,13 +3,7 @@ import { UseSelectStateChange } from "downshift";
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, SelectProps } from ":/components/Forms/Select"; import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select";
export interface Option {
value?: string;
label: string;
disabled?: boolean;
}
export const SelectMono = forwardRef<SelectHandle, SelectProps>( export const SelectMono = forwardRef<SelectHandle, SelectProps>(
(props, ref) => { (props, ref) => {

View File

@@ -23,3 +23,18 @@ export const RhfSelect = (props: SelectProps & { name: string }) => {
/> />
); );
}; };
export const getCountryOption = (name: string, code: string) => ({
value: name.toLowerCase(),
label: name,
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<img
style={{ height: "24px" }}
src={`https://flagsapi.com/${code}/shiny/64.png`}
alt="Flag"
/>
{name}
</div>
),
});

View File

@@ -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 <span>{option.label}</span>;
};