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. * under the License.
*/ */
import React, { ReactNode, useState, useCallback } from 'react'; 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 { export default {
title: 'Select', 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 = () => ( export const AtEveryCorner = () => (
<> <>
{selectPositions.map(position => ( {selectPositions.map(position => (
@ -73,6 +166,7 @@ export const AtEveryCorner = () => (
key={position.id} key={position.id}
style={{ style={{
...position.style, ...position.style,
margin: 30,
width: DEFAULT_WIDTH, width: DEFAULT_WIDTH,
position: 'absolute', 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 = [ const USERS = [
'John', 'John',
'Liam', 'Liam',
@ -155,71 +299,90 @@ const USERS = [
'Ilenia', 'Ilenia',
]; ];
export const AsyncSelect = ( export const AsyncSelect = ({
args: SelectProps & { withError: boolean; responseTime: number }, withError,
) => { responseTime,
paginatedFetch,
...rest
}: SelectProps & {
withError: boolean;
responseTime: number;
}) => {
const [requests, setRequests] = useState<ReactNode[]>([]); 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( const fetchUserList = useCallback(
(search: string, page = 0): Promise<OptionsPromiseResult> => { (search: string): Promise<OptionsType> => {
const username = search.trim().toLowerCase(); const username = search.trim().toLowerCase();
return new Promise(resolve => { return new Promise(resolve => {
let results: { label: string; value: string }[] = []; const results = getResults(username);
setRequestLog(username, results.length, results.length);
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,
};
setTimeout(() => { setTimeout(() => {
resolve(result); resolve(results);
}, args.responseTime * 1000); }, responseTime * 1000);
}); });
}, },
[args.responseTime], [responseTime],
); );
async function fetchUserListError(): Promise<OptionsPromiseResult> { const fetchUserListPage = useCallback(
return new Promise((_, reject) => { (
// eslint-disable-next-line prefer-promise-reject-errors search: string,
reject('This is an error'); 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 ( return (
<> <>
@ -229,8 +392,15 @@ export const AsyncSelect = (
}} }}
> >
<Select <Select
{...args} {...rest}
options={args.withError ? fetchUserListError : fetchUserList} paginatedFetch={paginatedFetch}
options={
withError
? fetchUserListError
: paginatedFetch
? fetchUserListPage
: fetchUserList
}
/> />
</div> </div>
<div <div
@ -245,8 +415,8 @@ export const AsyncSelect = (
padding: 20, padding: 20,
}} }}
> >
{requests.map(request => ( {requests.map((request, index) => (
<p>{request}</p> <p key={`request-${index}`}>{request}</p>
))} ))}
</div> </div>
</> </>
@ -255,20 +425,38 @@ export const AsyncSelect = (
AsyncSelect.args = { AsyncSelect.args = {
withError: false, withError: false,
allowNewOptions: false,
paginatedFetch: false, paginatedFetch: false,
pageSize: 10,
allowNewOptions: false,
}; };
AsyncSelect.argTypes = { AsyncSelect.argTypes = {
mode: { ...ARG_TYPES,
control: { type: 'select', options: ['single', 'multiple', 'tags'] }, header: {
table: {
disable: true,
},
},
invertSelection: {
table: {
disable: true,
},
},
pageSize: {
defaultValue: 10,
control: {
type: 'range',
min: 10,
max: 50,
step: 10,
},
}, },
responseTime: { responseTime: {
defaultValue: 1, defaultValue: 0.5,
name: 'responseTime (seconds)', name: 'responseTime (seconds)',
control: { control: {
type: 'range', type: 'range',
min: 1, min: 0.5,
max: 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' | 'defaultValue'
| 'disabled' | 'disabled'
| 'filterOption' | 'filterOption'
| 'loading'
| 'mode'
| 'notFoundContent'
| 'onChange' | 'onChange'
| 'placeholder' | 'placeholder'
| 'showSearch' | 'showSearch'
@ -59,30 +56,29 @@ type PickedSelectProps = Pick<
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>; export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
export type OptionsPromiseResult = { export type OptionsTypePage = {
data: OptionsType; data: OptionsType;
hasMoreData: boolean; totalCount: number;
}; };
export type OptionsPromise = ( export type OptionsPromise = (search: string) => Promise<OptionsType>;
search: string,
page?: number,
) => Promise<OptionsPromiseResult>;
export enum ESelectTypes { export type OptionsPagePromise = (
MULTIPLE = 'multiple', search: string,
TAGS = 'tags', offset: number,
SINGLE = '', limit: number,
} ) => Promise<OptionsTypePage>;
export interface SelectProps extends PickedSelectProps { export interface SelectProps extends PickedSelectProps {
allowNewOptions?: boolean; allowNewOptions?: boolean;
ariaLabel: string; ariaLabel: string;
header?: ReactNode; header?: ReactNode;
mode?: 'single' | 'multiple';
name?: string; // discourage usage name?: string; // discourage usage
notFoundContent?: ReactNode; options: OptionsType | OptionsPromise | OptionsPagePromise;
options: OptionsType | OptionsPromise;
paginatedFetch?: boolean; paginatedFetch?: boolean;
pageSize?: number;
invertSelection?: boolean;
} }
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -90,80 +86,86 @@ const StyledContainer = styled.div`
flex-direction: column; 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 MAX_TAG_COUNT = 4;
const TOKEN_SEPARATORS = [',', '\n', '\t', ';']; const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
const DEBOUNCE_TIMEOUT = 500; const DEBOUNCE_TIMEOUT = 500;
const DEFAULT_PAGE_SIZE = 50;
const Error = ({ error }: { error: string }) => { const Error = ({ error }: { error: string }) => (
const StyledError = styled.div` <StyledError>
display: flex; <Icons.ErrorSolid /> {error}
justify-content: center; </StyledError>
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 Select = ({ const Select = ({
allowNewOptions = false, allowNewOptions = false,
ariaLabel, ariaLabel,
filterOption, filterOption,
header = null, header = null,
loading, mode = 'single',
mode,
name, name,
notFoundContent, paginatedFetch,
paginatedFetch = false, pageSize = DEFAULT_PAGE_SIZE,
placeholder = t('Select ...'), placeholder = t('Select ...'),
options, options,
showSearch, showSearch,
invertSelection = false,
value, value,
...props ...props
}: SelectProps) => { }: SelectProps) => {
const isAsync = typeof options === 'function'; const isAsync = typeof options === 'function';
const isSingleMode = const isSingleMode = mode === 'single';
mode !== ESelectTypes.TAGS && mode !== ESelectTypes.MULTIPLE;
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch; const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
const initialOptions = options && Array.isArray(options) ? options : []; const initialOptions = options && Array.isArray(options) ? options : [];
const [selectOptions, setOptions] = useState<OptionsType>(initialOptions); const [selectOptions, setOptions] = useState<OptionsType>(initialOptions);
const [selectValue, setSelectValue] = useState(value); const [selectValue, setSelectValue] = useState(value);
const [searchedValue, setSearchedValue] = useState(''); const [searchedValue, setSearchedValue] = useState('');
const [isLoading, setLoading] = useState(loading); const [isLoading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [hasMoreData, setHasMoreData] = useState(false); const [offset, setOffset] = useState(0);
const fetchRef = useRef(0); const [totalCount, setTotalCount] = useState(0);
const fetchedQueries = useRef(new Set<string>());
const mappedMode = isSingleMode
? undefined
: allowNewOptions
? 'tags'
: 'multiple';
const handleSelectMode = () => { const handleTopOptions = (selectedValue: AntdSelectValue | undefined) => {
if (allowNewOptions && mode === ESelectTypes.MULTIPLE) {
return ESelectTypes.TAGS;
}
if (!allowNewOptions && mode === ESelectTypes.TAGS) {
return ESelectTypes.MULTIPLE;
}
return mode;
};
const handleTopOptions = (selectedValue: any) => {
// bringing selected options to the top of the list // bringing selected options to the top of the list
if (selectedValue) { if (selectedValue) {
const currentValue = selectedValue as string[] | string; const currentValue = selectedValue as string[] | string;
@ -187,61 +189,98 @@ const Select = ({
} }
}; };
const handleOnSelect = (selectedValue: any) => { const handleOnSelect = (
if (!isSingleMode) { selectedValue: string | number | AntdLabeledValue,
const currentSelected = Array.isArray(selectValue) ? selectValue : []; ) => {
setSelectValue([...currentSelected, selectedValue]); if (isSingleMode) {
} else {
setSelectValue(selectedValue); setSelectValue(selectedValue);
// in single mode the sorting must happen on selection // in single mode the sorting must happen on selection
handleTopOptions(selectedValue); 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)) { if (Array.isArray(selectValue)) {
const selectedValues = [ const selectedValues = [
...(selectValue as []).filter(opt => opt !== value), ...(selectValue as []).filter(opt => opt !== value),
]; ];
setSelectValue(selectedValues); 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( const handleFetch = useMemo(
() => (value: string, paginate?: 'paginate') => { () => (value: string) => {
if (paginate) { if (fetchedQueries.current.has(value)) {
fetchRef.current += 1; return;
} else {
fetchRef.current = 0;
} }
const fetchId = fetchRef.current; setLoading(true);
const page = paginatedFetch ? fetchId : undefined;
const fetchOptions = options as OptionsPromise; const fetchOptions = options as OptionsPromise;
fetchOptions(value, page) fetchOptions(value)
.then((result: OptionsPromiseResult) => { .then((data: OptionsType) => {
const { data, hasMoreData } = result; handleData(data);
setHasMoreData(hasMoreData); fetchedQueries.current.add(value);
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),
),
]);
}
}) })
.catch(response => .catch(onError)
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
}),
)
.finally(() => setLoading(false)); .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) => { const handleOnSearch = debounce((search: string) => {
@ -273,13 +312,16 @@ const Select = ({
const handlePagination = (e: UIEvent<HTMLElement>) => { const handlePagination = (e: UIEvent<HTMLElement>) => {
const vScroll = e.currentTarget; const vScroll = e.currentTarget;
if ( const thresholdReached =
hasMoreData && vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
isAsync && const hasMoreData = offset + pageSize < totalCount;
paginatedFetch &&
vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight if (!isLoading && isAsync && hasMoreData && thresholdReached) {
) { const newOffset = offset + pageSize;
handleFetch(searchedValue, 'paginate'); const limit =
newOffset + pageSize > totalCount ? totalCount - newOffset : pageSize;
handlePaginatedFetch(searchedValue, newOffset, limit);
setOffset(newOffset);
} }
}; };
@ -315,18 +357,24 @@ const Select = ({
useEffect(() => { useEffect(() => {
const foundOption = hasOption(searchedValue, selectOptions); const foundOption = hasOption(searchedValue, selectOptions);
if (isAsync && !foundOption && !allowNewOptions) { if (isAsync && !foundOption) {
setLoading(true); if (paginatedFetch) {
handleFetch(searchedValue); const offset = 0;
handlePaginatedFetch(searchedValue, offset, pageSize);
setOffset(offset);
} else {
handleFetch(searchedValue);
}
} }
}, [allowNewOptions, isAsync, handleFetch, searchedValue, selectOptions]); }, [
isAsync,
useEffect(() => { handleFetch,
if (isAsync && allowNewOptions) { searchedValue,
setLoading(true); selectOptions,
handleFetch(searchedValue); pageSize,
} paginatedFetch,
}, [allowNewOptions, isAsync, handleFetch, searchedValue]); handlePaginatedFetch,
]);
const dropdownRender = ( const dropdownRender = (
originNode: ReactElement & { ref?: RefObject<HTMLElement> }, originNode: ReactElement & { ref?: RefObject<HTMLElement> },
@ -334,32 +382,40 @@ const Select = ({
if (!isDropdownVisible) { if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 }); originNode.ref?.current?.scrollTo({ top: 0 });
} }
return <DropdownContent content={originNode} error={error} />; return error ? <Error error={error} /> : originNode;
}; };
return ( return (
<StyledContainer> <StyledContainer>
{header} {header}
<AntdSelect <StyledSelect
hasHeader={!!header}
aria-label={ariaLabel || name} aria-label={ariaLabel || name}
dropdownRender={dropdownRender} dropdownRender={dropdownRender}
filterOption={handleFilterOption as any} filterOption={handleFilterOption}
getPopupContainer={triggerNode => triggerNode.parentNode} getPopupContainer={triggerNode => triggerNode.parentNode}
loading={isLoading} loading={isLoading}
maxTagCount={MAX_TAG_COUNT} maxTagCount={MAX_TAG_COUNT}
mode={handleSelectMode()} mode={mappedMode}
notFoundContent={isLoading ? null : notFoundContent}
onDeselect={handleOnDeselect} onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange} onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={handlePagination} onPopupScroll={paginatedFetch ? handlePagination : undefined}
onSearch={handleOnSearch} onSearch={handleOnSearch}
onSelect={handleOnSelect} onSelect={handleOnSelect}
onClear={() => setSelectValue(undefined)}
options={selectOptions} options={selectOptions}
placeholder={shouldShowSearch ? t('Search ...') : placeholder} placeholder={placeholder}
showSearch={shouldShowSearch} showSearch={shouldShowSearch}
showArrow
tokenSeparators={TOKEN_SEPARATORS} tokenSeparators={TOKEN_SEPARATORS}
value={selectValue} value={selectValue}
style={{ width: '100%' }} menuItemSelectedIcon={
invertSelection ? (
<StyledStopOutlined iconSize="m" />
) : (
<StyledCheckOutlined iconSize="m" />
)
}
{...props} {...props}
/> />
</StyledContainer> </StyledContainer>