From 2c3000adcdf54cf2af7fe04b48e01022c0395d60 Mon Sep 17 00:00:00 2001 From: Haider Alshamma Date: Tue, 14 Nov 2023 16:19:59 -0500 Subject: [PATCH] fix: improve AsyncSelect UX - Remove no options panel when there are no options - Add a default placeholder - Improve TypeScript support --- src/AsyncSelect/AsyncSelect.tsx | 95 ++++++++++------------- src/AsyncSelect/AsyncSelectComponents.tsx | 92 ++++++++++++++++++++++ src/Select/customReactSelectStyles.tsx | 25 +++++- src/utils/story/simulatedAPIRequest.ts | 2 +- 4 files changed, 156 insertions(+), 58 deletions(-) create mode 100644 src/AsyncSelect/AsyncSelectComponents.tsx diff --git a/src/AsyncSelect/AsyncSelect.tsx b/src/AsyncSelect/AsyncSelect.tsx index d94251773..11942a9ac 100644 --- a/src/AsyncSelect/AsyncSelect.tsx +++ b/src/AsyncSelect/AsyncSelect.tsx @@ -1,65 +1,49 @@ -import React, { useContext, forwardRef } from "react"; +import React, { forwardRef, ReactNode, MutableRefObject } from "react"; +import Select from "react-select/base"; import AsyncReactSelect from "react-select/async"; +import { AsyncProps } from "react-select/async"; +import { GroupBase } from "react-select"; import { useTranslation } from "react-i18next"; -import { ThemeContext } from "styled-components"; +import { useTheme } from "styled-components"; import propTypes from "@styled-system/prop-types"; import { Field } from "../Form"; import { MaybeFieldLabel } from "../FieldLabel"; import { InlineValidation } from "../Validation"; import customStyles from "../Select/customReactSelectStyles"; import { SelectOption } from "../Select/SelectOption"; +import { getSubset } from "../utils/subset"; import { SelectControl, SelectMultiValue, SelectClearIndicator, SelectContainer, - SelectMenu, SelectInput, SelectDropdownIndicator, -} from "../Select"; -import { getSubset } from "../utils/subset"; + SelectMenu, +} from "./AsyncSelectComponents"; -type AsyncSelectProps = { - windowThreshold?: number; - isClearable?: boolean; - filterOption?: (...args: any[]) => any; - autocomplete?: boolean; - disabled?: boolean; - error?: boolean; - errorMessage?: string; - errorList?: string[]; +type AsyncCustomProps> = { + autocomplete?: AsyncProps["isSearchable"]; labelText?: string; - helpText?: any; - noOptionsMessage?: string; requirementText?: string; - id?: string; - initialIsOpen?: boolean; - menuPosition?: string; + helpText?: ReactNode; + disabled?: AsyncProps["isDisabled"]; + errorMessage?: string; + errorList?: string[]; + initialIsOpen?: AsyncProps["defaultMenuIsOpen"]; + multiselect?: AsyncProps["isMulti"]; maxHeight?: string; - multiselect?: boolean; - name?: string; - onBlur?: (...args: any[]) => any; - onChange?: (...args: any[]) => any; - placeholder?: string; - required?: boolean; - value?: any; - defaultValue?: any; - className?: string; - classNamePrefix?: string; - menuIsOpen?: boolean; - onMenuOpen?: (...args: any[]) => any; - onMenuClose?: (...args: any[]) => any; - onInputChange?: (...args: any[]) => any; - components?: any; - closeMenuOnSelect?: boolean; - "aria-label"?: string; - cacheOptions?: boolean; - defaultOptions?: Array; - loadOptions: any; + defaultValue?: AsyncProps["defaultInputValue"]; }; +type AsyncSelectProps> = Omit< + AsyncProps, + "isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue" +> & + AsyncCustomProps; + const AsyncSelect = forwardRef( - ( + >( { autocomplete, labelText, @@ -70,7 +54,6 @@ const AsyncSelect = forwardRef( disabled, errorMessage, errorList, - error = !!(errorMessage || errorList), id, initialIsOpen, maxHeight, @@ -95,12 +78,17 @@ const AsyncSelect = forwardRef( loadOptions, isClearable, ...props - }: AsyncSelectProps, - ref + }: AsyncSelectProps, + ref: + | ((instance: Select | null) => void) + | MutableRefObject | null> + | null ) => { const { t } = useTranslation(); - const themeContext = useContext(ThemeContext); + const theme = useTheme(); const spaceProps = getSubset(props, propTypes.space); + const error = !!(errorMessage || errorList); + return ( @@ -112,14 +100,15 @@ const AsyncSelect = forwardRef( ref={ref} defaultInputValue={defaultValue} placeholder={placeholder || t("start typing")} - labelText={labelText} - styles={customStyles({ - theme: themeContext, - error, - maxHeight, - windowed: false, - hasDefaultOptions: Boolean(defaultOptions), - })} + styles={ + customStyles({ + theme, + error, + maxHeight, + windowed: false, + hasDefaultOptions: Boolean(defaultOptions), + }) as any + } isDisabled={disabled} isSearchable={autocomplete} aria-required={required} @@ -136,7 +125,7 @@ const AsyncSelect = forwardRef( onMenuClose={onMenuClose} menuPosition={menuPosition} onInputChange={onInputChange} - theme={themeContext} + theme={theme as any} components={{ Option: SelectOption, Control: SelectControl, diff --git a/src/AsyncSelect/AsyncSelectComponents.tsx b/src/AsyncSelect/AsyncSelectComponents.tsx new file mode 100644 index 000000000..d73eb8cc4 --- /dev/null +++ b/src/AsyncSelect/AsyncSelectComponents.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { + ClearIndicatorProps, + components as selectComponents, + ContainerProps, + ControlProps, + DropdownIndicatorProps, + InputProps, + MenuProps, + MultiValueProps, +} from "react-select"; +import { components, GroupBase } from "react-select"; + +export const SelectControl = >( + props: ControlProps +) => { + // eslint-disable-next-line react/prop-types + const { isFocused } = props; + return ( +
+ +
+ ); +}; + +export const SelectMultiValue = >( + props: MultiValueProps +) => { + return ( +
+ +
+ ); +}; + +export const SelectClearIndicator = >( + props: ClearIndicatorProps +) => { + return ( +
+ +
+ ); +}; + +export const SelectDropdownIndicator = >( + props: DropdownIndicatorProps +) => { + return ( +
+ +
+ ); +}; + +export const SelectContainer = >( + props: ContainerProps +) => { + return ( +
+ +
+ ); +}; + +export const SelectInput = >( + props: InputProps +) => { + return ( +
+ +
+ ); +}; + +export const SelectMenu = >( + props: MenuProps +) => { + if (!props.selectProps.inputValue && props.options.length === 0) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/Select/customReactSelectStyles.tsx b/src/Select/customReactSelectStyles.tsx index 9e07762e3..a94460fba 100644 --- a/src/Select/customReactSelectStyles.tsx +++ b/src/Select/customReactSelectStyles.tsx @@ -83,6 +83,7 @@ const customStyles = ({ height: 38, }), control: (provided, state) => ({ + ...provided, display: "flex", minHeight: theme.space.x5, paddingLeft: theme.space.x1, @@ -156,9 +157,18 @@ const customStyles = ({ ...provided, color: theme.colors.grey, }), + singleValue: (provided) => ({ + ...provided, + marginLeft: 2, + marginRight: 2, + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + }), input: () => ({}), valueContainer: (provided, state) => ({ ...provided, + display: "flex", padding: 0, overflow: "auto", maxHeight: "150px", @@ -299,10 +309,17 @@ const customStyles = ({ : "none", borderLeft: `1px solid ${theme.colors.grey}`, }), - placeholder: (provided, state) => ({ - ...provided, - color: state.isDisabled ? transparentize(0.6667, theme.colors.black) : "hsl(0,0%,50%)", - }), + placeholder: (state) => { + return { + label: "placeholder", + marginLeft: 2, + marginRight: 2, + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + color: state.isDisabled ? transparentize(0.6667, theme.colors.black) : "hsl(0,0%,50%)", + }; + }, }; }; diff --git a/src/utils/story/simulatedAPIRequest.ts b/src/utils/story/simulatedAPIRequest.ts index 87e673b34..d044f6694 100644 --- a/src/utils/story/simulatedAPIRequest.ts +++ b/src/utils/story/simulatedAPIRequest.ts @@ -4,7 +4,7 @@ const simulatedAPIRequest = async ( milliseconds = 450 ): Promise => { const country = data.find((country) => { - return country.value.toLowerCase().startsWith(inputValue); + return country.value.toLowerCase().startsWith(inputValue.toLowerCase()); }); const responseBody = JSON.stringify([{ name: country.value }]);