chore: Improves the Select component UI/UX - iteration 3 (#15363)

This commit is contained in:
Michael S. Molina
2021-06-25 14:01:39 -03:00
committed by GitHub
parent 95b9e2e185
commit a7e103765a
2 changed files with 433 additions and 219 deletions

View File

@@ -48,9 +48,6 @@ type PickedSelectProps = Pick<
| 'defaultValue'
| 'disabled'
| 'filterOption'
| 'loading'
| 'mode'
| 'notFoundContent'
| 'onChange'
| 'placeholder'
| 'showSearch'
@@ -59,30 +56,29 @@ type PickedSelectProps = Pick<
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
export type OptionsPromiseResult = {
export type OptionsTypePage = {
data: OptionsType;
hasMoreData: boolean;
totalCount: number;
};
export type OptionsPromise = (
search: string,
page?: number,
) => Promise<OptionsPromiseResult>;
export type OptionsPromise = (search: string) => Promise<OptionsType>;
export enum ESelectTypes {
MULTIPLE = 'multiple',
TAGS = 'tags',
SINGLE = '',
}
export type OptionsPagePromise = (
search: string,
offset: number,
limit: number,
) => Promise<OptionsTypePage>;
export interface SelectProps extends PickedSelectProps {
allowNewOptions?: boolean;
ariaLabel: string;
header?: ReactNode;
mode?: 'single' | 'multiple';
name?: string; // discourage usage
notFoundContent?: ReactNode;
options: OptionsType | OptionsPromise;
options: OptionsType | OptionsPromise | OptionsPagePromise;
paginatedFetch?: boolean;
pageSize?: number;
invertSelection?: boolean;
}
const StyledContainer = styled.div`
@@ -90,80 +86,86 @@ const StyledContainer = styled.div`
flex-direction: column;
`;
// unexposed default behaviors
const StyledSelect = styled(AntdSelect, {
shouldForwardProp: prop => prop !== 'hasHeader',
})<{ hasHeader: boolean }>`
${({ theme, hasHeader }) => `
width: 100%;
margin-top: ${hasHeader ? theme.gridUnit : 0}px;
`}
`;
const StyledStopOutlined = styled(Icons.StopOutlined)`
vertical-align: 0;
`;
const StyledCheckOutlined = styled(Icons.CheckOutlined)`
vertical-align: 0;
`;
const StyledError = styled.div`
${({ theme }) => `
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
padding: ${theme.gridUnit * 2}px;
color: ${theme.colors.error.base};
& svg {
margin-right: ${theme.gridUnit * 2}px;
}
`}
`;
// default behaviors
const MAX_TAG_COUNT = 4;
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
const DEBOUNCE_TIMEOUT = 500;
const DEFAULT_PAGE_SIZE = 50;
const Error = ({ error }: { error: string }) => {
const StyledError = styled.div`
display: flex;
justify-content: center;
width: 100%;
color: ${({ theme }) => theme.colors.error};
`;
return (
<StyledError>
<Icons.Error /> {error}
</StyledError>
);
};
const DropdownContent = ({
content,
error,
}: {
content: ReactElement;
error?: string;
loading?: boolean;
}) => {
if (error) {
return <Error error={error} />;
}
return content;
};
const Error = ({ error }: { error: string }) => (
<StyledError>
<Icons.ErrorSolid /> {error}
</StyledError>
);
const Select = ({
allowNewOptions = false,
ariaLabel,
filterOption,
header = null,
loading,
mode,
mode = 'single',
name,
notFoundContent,
paginatedFetch = false,
paginatedFetch,
pageSize = DEFAULT_PAGE_SIZE,
placeholder = t('Select ...'),
options,
showSearch,
invertSelection = false,
value,
...props
}: SelectProps) => {
const isAsync = typeof options === 'function';
const isSingleMode =
mode !== ESelectTypes.TAGS && mode !== ESelectTypes.MULTIPLE;
const isSingleMode = mode === 'single';
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
const initialOptions = options && Array.isArray(options) ? options : [];
const [selectOptions, setOptions] = useState<OptionsType>(initialOptions);
const [selectValue, setSelectValue] = useState(value);
const [searchedValue, setSearchedValue] = useState('');
const [isLoading, setLoading] = useState(loading);
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [hasMoreData, setHasMoreData] = useState(false);
const fetchRef = useRef(0);
const [offset, setOffset] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const fetchedQueries = useRef(new Set<string>());
const mappedMode = isSingleMode
? undefined
: allowNewOptions
? 'tags'
: 'multiple';
const handleSelectMode = () => {
if (allowNewOptions && mode === ESelectTypes.MULTIPLE) {
return ESelectTypes.TAGS;
}
if (!allowNewOptions && mode === ESelectTypes.TAGS) {
return ESelectTypes.MULTIPLE;
}
return mode;
};
const handleTopOptions = (selectedValue: any) => {
const handleTopOptions = (selectedValue: AntdSelectValue | undefined) => {
// bringing selected options to the top of the list
if (selectedValue) {
const currentValue = selectedValue as string[] | string;
@@ -187,61 +189,98 @@ const Select = ({
}
};
const handleOnSelect = (selectedValue: any) => {
if (!isSingleMode) {
const currentSelected = Array.isArray(selectValue) ? selectValue : [];
setSelectValue([...currentSelected, selectedValue]);
} else {
const handleOnSelect = (
selectedValue: string | number | AntdLabeledValue,
) => {
if (isSingleMode) {
setSelectValue(selectedValue);
// in single mode the sorting must happen on selection
handleTopOptions(selectedValue);
} else {
const currentSelected = Array.isArray(selectValue) ? selectValue : [];
if (
typeof selectedValue === 'number' ||
typeof selectedValue === 'string'
) {
setSelectValue([
...(currentSelected as (string | number)[]),
selectedValue as string | number,
]);
} else {
setSelectValue([
...(currentSelected as AntdLabeledValue[]),
selectedValue as AntdLabeledValue,
]);
}
}
setSearchedValue('');
};
const handleOnDeselect = (value: any) => {
const handleOnDeselect = (value: string | number | AntdLabeledValue) => {
if (Array.isArray(selectValue)) {
const selectedValues = [
...(selectValue as []).filter(opt => opt !== value),
];
setSelectValue(selectedValues);
}
setSearchedValue('');
};
const onError = (response: Response) =>
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
});
const handleData = (data: OptionsType) => {
if (data && Array.isArray(data) && data.length) {
// merges with existing and creates unique options
setOptions(prevOptions => [
...prevOptions,
...data.filter(
newOpt =>
!prevOptions.find(prevOpt => prevOpt.value === newOpt.value),
),
]);
}
};
const handleFetch = useMemo(
() => (value: string, paginate?: 'paginate') => {
if (paginate) {
fetchRef.current += 1;
} else {
fetchRef.current = 0;
() => (value: string) => {
if (fetchedQueries.current.has(value)) {
return;
}
const fetchId = fetchRef.current;
const page = paginatedFetch ? fetchId : undefined;
setLoading(true);
const fetchOptions = options as OptionsPromise;
fetchOptions(value, page)
.then((result: OptionsPromiseResult) => {
const { data, hasMoreData } = result;
setHasMoreData(hasMoreData);
if (fetchId !== fetchRef.current) return;
if (data && Array.isArray(data) && data.length) {
// merges with existing and creates unique options
setOptions(prevOptions => [
...prevOptions,
...data.filter(
newOpt =>
!prevOptions.find(prevOpt => prevOpt.value === newOpt.value),
),
]);
}
fetchOptions(value)
.then((data: OptionsType) => {
handleData(data);
fetchedQueries.current.add(value);
})
.catch(response =>
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
}),
)
.catch(onError)
.finally(() => setLoading(false));
},
[options, paginatedFetch],
[options],
);
const handlePaginatedFetch = useMemo(
() => (value: string, offset: number, limit: number) => {
const key = `${value};${offset};${limit}`;
if (fetchedQueries.current.has(key)) {
return;
}
setLoading(true);
const fetchOptions = options as OptionsPagePromise;
fetchOptions(value, offset, limit)
.then(({ data, totalCount }: OptionsTypePage) => {
handleData(data);
fetchedQueries.current.add(key);
setTotalCount(totalCount);
})
.catch(onError)
.finally(() => setLoading(false));
},
[options],
);
const handleOnSearch = debounce((search: string) => {
@@ -273,13 +312,16 @@ const Select = ({
const handlePagination = (e: UIEvent<HTMLElement>) => {
const vScroll = e.currentTarget;
if (
hasMoreData &&
isAsync &&
paginatedFetch &&
vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight
) {
handleFetch(searchedValue, 'paginate');
const thresholdReached =
vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
const hasMoreData = offset + pageSize < totalCount;
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
const newOffset = offset + pageSize;
const limit =
newOffset + pageSize > totalCount ? totalCount - newOffset : pageSize;
handlePaginatedFetch(searchedValue, newOffset, limit);
setOffset(newOffset);
}
};
@@ -315,18 +357,24 @@ const Select = ({
useEffect(() => {
const foundOption = hasOption(searchedValue, selectOptions);
if (isAsync && !foundOption && !allowNewOptions) {
setLoading(true);
handleFetch(searchedValue);
if (isAsync && !foundOption) {
if (paginatedFetch) {
const offset = 0;
handlePaginatedFetch(searchedValue, offset, pageSize);
setOffset(offset);
} else {
handleFetch(searchedValue);
}
}
}, [allowNewOptions, isAsync, handleFetch, searchedValue, selectOptions]);
useEffect(() => {
if (isAsync && allowNewOptions) {
setLoading(true);
handleFetch(searchedValue);
}
}, [allowNewOptions, isAsync, handleFetch, searchedValue]);
}, [
isAsync,
handleFetch,
searchedValue,
selectOptions,
pageSize,
paginatedFetch,
handlePaginatedFetch,
]);
const dropdownRender = (
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
@@ -334,32 +382,40 @@ const Select = ({
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return <DropdownContent content={originNode} error={error} />;
return error ? <Error error={error} /> : originNode;
};
return (
<StyledContainer>
{header}
<AntdSelect
<StyledSelect
hasHeader={!!header}
aria-label={ariaLabel || name}
dropdownRender={dropdownRender}
filterOption={handleFilterOption as any}
filterOption={handleFilterOption}
getPopupContainer={triggerNode => triggerNode.parentNode}
loading={isLoading}
maxTagCount={MAX_TAG_COUNT}
mode={handleSelectMode()}
notFoundContent={isLoading ? null : notFoundContent}
mode={mappedMode}
onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={handlePagination}
onPopupScroll={paginatedFetch ? handlePagination : undefined}
onSearch={handleOnSearch}
onSelect={handleOnSelect}
onClear={() => setSelectValue(undefined)}
options={selectOptions}
placeholder={shouldShowSearch ? t('Search ...') : placeholder}
placeholder={placeholder}
showSearch={shouldShowSearch}
showArrow
tokenSeparators={TOKEN_SEPARATORS}
value={selectValue}
style={{ width: '100%' }}
menuItemSelectedIcon={
invertSelection ? (
<StyledStopOutlined iconSize="m" />
) : (
<StyledCheckOutlined iconSize="m" />
)
}
{...props}
/>
</StyledContainer>