Skip to content

Commit

Permalink
fix: improve AsyncSelect UX
Browse files Browse the repository at this point in the history
- Remove no options panel when there are no options
- Add a default placeholder
- Improve TypeScript support
  • Loading branch information
haideralsh committed Nov 15, 2023
1 parent cf125d3 commit 2c3000a
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 58 deletions.
95 changes: 42 additions & 53 deletions src/AsyncSelect/AsyncSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = {
autocomplete?: AsyncProps<Option, IsMulti, Group>["isSearchable"];
labelText?: string;
helpText?: any;
noOptionsMessage?: string;
requirementText?: string;
id?: string;
initialIsOpen?: boolean;
menuPosition?: string;
helpText?: ReactNode;
disabled?: AsyncProps<Option, IsMulti, Group>["isDisabled"];
errorMessage?: string;
errorList?: string[];
initialIsOpen?: AsyncProps<Option, IsMulti, Group>["defaultMenuIsOpen"];
multiselect?: AsyncProps<Option, IsMulti, Group>["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<any>;
loadOptions: any;
defaultValue?: AsyncProps<Option, IsMulti, Group>["defaultInputValue"];
};

type AsyncSelectProps<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = Omit<
AsyncProps<Option, IsMulti, Group>,
"isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue"
> &
AsyncCustomProps<Option, IsMulti, Group>;

const AsyncSelect = forwardRef(
(
<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
{
autocomplete,
labelText,
Expand All @@ -70,7 +54,6 @@ const AsyncSelect = forwardRef(
disabled,
errorMessage,
errorList,
error = !!(errorMessage || errorList),
id,
initialIsOpen,
maxHeight,
Expand All @@ -95,12 +78,17 @@ const AsyncSelect = forwardRef(
loadOptions,
isClearable,
...props
}: AsyncSelectProps,
ref
}: AsyncSelectProps<Option, IsMulti, Group>,
ref:
| ((instance: Select<Option, IsMulti, Group> | null) => void)
| MutableRefObject<Select<Option, IsMulti, Group> | null>
| null
) => {
const { t } = useTranslation();
const themeContext = useContext(ThemeContext);
const theme = useTheme();
const spaceProps = getSubset(props, propTypes.space);
const error = !!(errorMessage || errorList);

return (
<Field {...spaceProps}>
<MaybeFieldLabel labelText={labelText} requirementText={requirementText} helpText={helpText}>
Expand All @@ -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}
Expand All @@ -136,7 +125,7 @@ const AsyncSelect = forwardRef(
onMenuClose={onMenuClose}
menuPosition={menuPosition}
onInputChange={onInputChange}
theme={themeContext}
theme={theme as any}
components={{
Option: SelectOption,
Control: SelectControl,
Expand Down
92 changes: 92 additions & 0 deletions src/AsyncSelect/AsyncSelectComponents.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: ControlProps<Option, IsMulti, Group>
) => {
// eslint-disable-next-line react/prop-types
const { isFocused } = props;
return (
<div data-testid="select-control">
<selectComponents.Control
className={isFocused ? "nds-select--is-focused" : null}
isFocused={isFocused}
{...props}
/>
</div>
);
};

export const SelectMultiValue = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: MultiValueProps<Option, IsMulti, Group>
) => {
return (
<div data-testid="select-multivalue">
<selectComponents.MultiValue {...props} />
</div>
);
};

export const SelectClearIndicator = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: ClearIndicatorProps<Option, IsMulti, Group>
) => {
return (
<div data-testid="select-clear">
<selectComponents.ClearIndicator {...props} />
</div>
);
};

export const SelectDropdownIndicator = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: DropdownIndicatorProps<Option, IsMulti, Group>
) => {
return (
<div data-testid="select-arrow">
<selectComponents.DropdownIndicator {...props} />
</div>
);
};

export const SelectContainer = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: ContainerProps<Option, IsMulti, Group>
) => {
return (
<div data-testid="select-container">
<selectComponents.SelectContainer {...props} />
</div>
);
};

export const SelectInput = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: InputProps<Option, IsMulti, Group>
) => {
return (
<div data-testid="select-input">
<selectComponents.Input {...props} />
</div>
);
};

export const SelectMenu = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: MenuProps<Option, IsMulti, Group>
) => {
if (!props.selectProps.inputValue && props.options.length === 0) {
return null;
}

return (
<div data-testid="select-dropdown">
<components.Menu {...props} />
</div>
);
};
25 changes: 21 additions & 4 deletions src/Select/customReactSelectStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const customStyles = ({
height: 38,
}),
control: (provided, state) => ({
...provided,
display: "flex",
minHeight: theme.space.x5,
paddingLeft: theme.space.x1,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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%)",
};
},
};
};

Expand Down
2 changes: 1 addition & 1 deletion src/utils/story/simulatedAPIRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const simulatedAPIRequest = async (
milliseconds = 450
): Promise<Response> => {
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 }]);
Expand Down

0 comments on commit 2c3000a

Please sign in to comment.