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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 433 additions and 219 deletions

View File

@ -17,7 +17,8 @@
* under the License.
*/
import React, { ReactNode, useState, useCallback } from 'react';
import Select, { SelectProps, OptionsPromiseResult } from './Select';
import ControlHeader from 'src/explore/components/ControlHeader';
import Select, { SelectProps, OptionsType, OptionsTypePage } from './Select';
export default {
title: 'Select',
@ -66,6 +67,98 @@ const selectPositions = [
},
];
const ARG_TYPES = {
options: {
defaultValue: options,
table: {
disable: true,
},
},
ariaLabel: {
table: {
disable: true,
},
},
name: {
table: {
disable: true,
},
},
notFoundContent: {
table: {
disable: true,
},
},
mode: {
defaultValue: 'single',
control: {
type: 'inline-radio',
options: ['single', 'multiple'],
},
},
};
const mountHeader = (type: String) => {
let header;
if (type === 'text') {
header = 'Text header';
} else if (type === 'control') {
header = (
<ControlHeader
label="Control header"
warning="Example of warning messsage"
/>
);
}
return header;
};
export const InteractiveSelect = (args: SelectProps & { header: string }) => (
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select {...args} header={mountHeader(args.header)} />
</div>
);
InteractiveSelect.args = {
autoFocus: false,
allowNewOptions: false,
allowClear: false,
showSearch: false,
disabled: false,
invertSelection: false,
placeholder: 'Select ...',
};
InteractiveSelect.argTypes = {
...ARG_TYPES,
header: {
defaultValue: 'none',
control: { type: 'inline-radio', options: ['none', 'text', 'control'] },
},
pageSize: {
table: {
disable: true,
},
},
paginatedFetch: {
table: {
disable: true,
},
},
};
InteractiveSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};
export const AtEveryCorner = () => (
<>
{selectPositions.map(position => (
@ -73,6 +166,7 @@ export const AtEveryCorner = () => (
key={position.id}
style={{
...position.style,
margin: 30,
width: DEFAULT_WIDTH,
position: 'absolute',
}}
@ -102,6 +196,56 @@ AtEveryCorner.story = {
},
};
export const PageScroll = () => (
<div style={{ height: 2000, overflowY: 'auto' }}>
<div
style={{
width: DEFAULT_WIDTH,
position: 'absolute',
top: 30,
right: 30,
}}
>
<Select ariaLabel="page-scroll-select-1" options={options} />
</div>
<div
style={{
width: DEFAULT_WIDTH,
position: 'absolute',
bottom: 30,
right: 30,
}}
>
<Select ariaLabel="page-scroll-select-2" options={options} />
</div>
<p
style={{
position: 'absolute',
top: '40%',
left: 30,
width: 500,
}}
>
The objective of this panel is to show how the Select behaves when there's
a scroll on the page. In particular, how the drop-down is displayed.
</p>
</div>
);
PageScroll.story = {
parameters: {
actions: {
disable: true,
},
controls: {
disable: true,
},
knobs: {
disable: true,
},
},
};
const USERS = [
'John',
'Liam',
@ -155,71 +299,90 @@ const USERS = [
'Ilenia',
];
export const AsyncSelect = (
args: SelectProps & { withError: boolean; responseTime: number },
) => {
export const AsyncSelect = ({
withError,
responseTime,
paginatedFetch,
...rest
}: SelectProps & {
withError: boolean;
responseTime: number;
}) => {
const [requests, setRequests] = useState<ReactNode[]>([]);
const getResults = (username: string) => {
let results: { label: string; value: string }[] = [];
if (!username) {
results = USERS.map(u => ({
label: u,
value: u,
}));
} else {
const foundUsers = USERS.filter(u => u.toLowerCase().includes(username));
if (foundUsers) {
results = foundUsers.map(u => ({ label: u, value: u }));
} else {
results = [];
}
}
return results;
};
const setRequestLog = (username: string, results: number, total: number) => {
const request = (
<>
Emulating network request with search <b>{username || 'empty'}</b> ...{' '}
<b>
{results}/{total}
</b>{' '}
results
</>
);
setRequests(requests => [request, ...requests]);
};
const fetchUserList = useCallback(
(search: string, page = 0): Promise<OptionsPromiseResult> => {
(search: string): Promise<OptionsType> => {
const username = search.trim().toLowerCase();
return new Promise(resolve => {
let results: { label: string; value: string }[] = [];
if (!username) {
results = USERS.map(u => ({
label: u,
value: u,
}));
} else {
const foundUsers = USERS.find(u =>
u.toLowerCase().includes(username),
);
if (foundUsers && Array.isArray(foundUsers)) {
results = foundUsers.map(u => ({ label: u, value: u }));
}
if (foundUsers && typeof foundUsers === 'string') {
const u = foundUsers;
results = [{ label: u, value: u }];
}
}
const pageSize = 10;
const offset = !page ? 0 : page * pageSize;
const resultsNum = !page ? pageSize : (page + 1) * pageSize;
results = results.length ? results.splice(offset, resultsNum) : [];
const request = (
<>
Emulating network request for page <b>{page}</b> and search{' '}
<b>{username || 'empty'}</b> ... <b>{resultsNum}</b> results
</>
);
setRequests(requests => [request, ...requests]);
const totalPages =
USERS.length / pageSize + (USERS.length % pageSize > 0 ? 1 : 0);
const result: OptionsPromiseResult = {
data: results,
hasMoreData: page + 1 < totalPages,
};
const results = getResults(username);
setRequestLog(username, results.length, results.length);
setTimeout(() => {
resolve(result);
}, args.responseTime * 1000);
resolve(results);
}, responseTime * 1000);
});
},
[args.responseTime],
[responseTime],
);
async function fetchUserListError(): Promise<OptionsPromiseResult> {
return new Promise((_, reject) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject('This is an error');
const fetchUserListPage = useCallback(
(
search: string,
offset: number,
limit: number,
): Promise<OptionsTypePage> => {
const username = search.trim().toLowerCase();
return new Promise(resolve => {
let results = getResults(username);
const totalCount = results.length;
if (paginatedFetch) {
results = results.splice(offset, limit);
}
setRequestLog(username, offset + results.length, totalCount);
setTimeout(() => {
resolve({ data: results, totalCount });
}, responseTime * 1000);
});
},
[paginatedFetch, responseTime],
);
const fetchUserListError = async (): Promise<OptionsTypePage> =>
new Promise((_, reject) => {
reject(new Error('Error while fetching the names from the server'));
});
}
return (
<>
@ -229,8 +392,15 @@ export const AsyncSelect = (
}}
>
<Select
{...args}
options={args.withError ? fetchUserListError : fetchUserList}
{...rest}
paginatedFetch={paginatedFetch}
options={
withError
? fetchUserListError
: paginatedFetch
? fetchUserListPage
: fetchUserList
}
/>
</div>
<div
@ -245,8 +415,8 @@ export const AsyncSelect = (
padding: 20,
}}
>
{requests.map(request => (
<p>{request}</p>
{requests.map((request, index) => (
<p key={`request-${index}`}>{request}</p>
))}
</div>
</>
@ -255,20 +425,38 @@ export const AsyncSelect = (
AsyncSelect.args = {
withError: false,
allowNewOptions: false,
paginatedFetch: false,
pageSize: 10,
allowNewOptions: false,
};
AsyncSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
...ARG_TYPES,
header: {
table: {
disable: true,
},
},
invertSelection: {
table: {
disable: true,
},
},
pageSize: {
defaultValue: 10,
control: {
type: 'range',
min: 10,
max: 50,
step: 10,
},
},
responseTime: {
defaultValue: 1,
defaultValue: 0.5,
name: 'responseTime (seconds)',
control: {
type: 'range',
min: 1,
min: 0.5,
max: 5,
},
},
@ -281,33 +469,3 @@ AsyncSelect.story = {
},
},
};
export const InteractiveSelect = (args: SelectProps) => (
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select {...args} />
</div>
);
InteractiveSelect.args = {
allowNewOptions: false,
options,
showSearch: false,
};
InteractiveSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
},
};
InteractiveSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};

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>