chore: Improves the Select component UI/UX - iteration 2 (#15235)

This commit is contained in:
Michael S. Molina 2021-06-22 16:04:01 -03:00 committed by GitHub
parent 5b2eb8d933
commit fc1a62b78c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 361 additions and 278 deletions

View File

@ -1,244 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import Select, { OptionsType, SelectProps } from './AntdSelect';
export default {
title: 'Select',
component: Select,
};
const options = [
{
label: 'Such an incredibly awesome long long label',
value: 'Such an incredibly awesome long long label',
},
{
label: 'Another incredibly awesome long long label',
value: 'Another incredibly awesome long long label',
},
{ label: 'Just a label', value: 'Just a label' },
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
{ label: 'D', value: 'D' },
{ label: 'E', value: 'E' },
{ label: 'F', value: 'F' },
{ label: 'G', value: 'G' },
{ label: 'H', value: 'H' },
{ label: 'I', value: 'I' },
];
const selectPositions = [
{
id: 'topLeft',
style: { top: '0', left: '0' },
},
{
id: 'topRight',
style: { top: '0', right: '0' },
},
{
id: 'bottomLeft',
style: { bottom: '0', left: '0' },
},
{
id: 'bottomRight',
style: { bottom: '0', right: '0' },
},
];
export const AtEveryCorner = () => (
<>
{selectPositions.map(position => (
<div
key={position.id}
style={{
...position.style,
width: '120px',
position: 'absolute',
}}
>
<Select ariaLabel={`gallery-${position.id}`} options={options} />
</div>
))}
</>
);
AtEveryCorner.story = {
parameters: {
actions: {
disable: true,
},
controls: {
disable: true,
},
knobs: {
disable: true,
},
},
};
async function fetchUserList(search: string, page = 0): Promise<OptionsType> {
const username = search.trim().toLowerCase();
return new Promise(resolve => {
const users = [
'John',
'Liam',
'Olivia',
'Emma',
'Noah',
'Ava',
'Oliver',
'Elijah',
'Charlotte',
'Diego',
'Evan',
'Michael',
'Giovanni',
'Luca',
'Paolo',
'Francesca',
'Chiara',
'Sara',
'Valentina',
'Jessica',
'Angelica',
'Mario',
'Marco',
'Andrea',
'Luigi',
'Quarto',
'Quinto',
'Sesto',
'Franco',
'Sandro',
'Alehandro',
'Johnny',
'Nikole',
'Igor',
'Sipatha',
'Thami',
'Munei',
'Guilherme',
'Umair',
'Ashfaq',
'Amna',
'Irfan',
'George',
'Naseer',
'Mohammad',
'Rick',
'Saliya',
'Claire',
'Benedetta',
'Ilenia',
];
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 offset = !page ? 0 : page * 10;
const resultsNum = !page ? 10 : (page + 1) * 10;
results = results.length ? results.splice(offset, resultsNum) : [];
// eslint-disable-next-line no-console
console.warn(
`Emulating network request for search string: ${
username || '"empty"'
} and page: ${page} with results: [${results
.map(u => u.value)
.join(', ')}]`,
);
setTimeout(() => {
resolve(results);
}, 500);
});
}
async function fetchUserListError(): Promise<OptionsType> {
return new Promise((_, reject) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject('This is an error');
});
}
export const AsyncSelect = (args: SelectProps & { withError: boolean }) => (
<Select
{...args}
options={args.withError ? fetchUserListError : fetchUserList}
/>
);
AsyncSelect.args = {
withError: false,
allowNewOptions: false,
paginatedFetch: false,
};
AsyncSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
},
};
AsyncSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};
export const InteractiveSelect = (args: SelectProps) => <Select {...args} />;
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

@ -0,0 +1,313 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { ReactNode, useState, useCallback } from 'react';
import Select, { SelectProps, OptionsPromiseResult } from './Select';
export default {
title: 'Select',
component: Select,
};
const DEFAULT_WIDTH = 200;
const options = [
{
label: 'Such an incredibly awesome long long label',
value: 'Such an incredibly awesome long long label',
},
{
label: 'Another incredibly awesome long long label',
value: 'Another incredibly awesome long long label',
},
{ label: 'Just a label', value: 'Just a label' },
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
{ label: 'D', value: 'D' },
{ label: 'E', value: 'E' },
{ label: 'F', value: 'F' },
{ label: 'G', value: 'G' },
{ label: 'H', value: 'H' },
{ label: 'I', value: 'I' },
];
const selectPositions = [
{
id: 'topLeft',
style: { top: '0', left: '0' },
},
{
id: 'topRight',
style: { top: '0', right: '0' },
},
{
id: 'bottomLeft',
style: { bottom: '0', left: '0' },
},
{
id: 'bottomRight',
style: { bottom: '0', right: '0' },
},
];
export const AtEveryCorner = () => (
<>
{selectPositions.map(position => (
<div
key={position.id}
style={{
...position.style,
width: DEFAULT_WIDTH,
position: 'absolute',
}}
>
<Select ariaLabel={`gallery-${position.id}`} options={options} />
</div>
))}
<p style={{ position: 'absolute', top: '40%', left: '33%', width: 500 }}>
The objective of this panel is to show how the Select behaves when in
touch with the viewport extremities. In particular, how the drop-down is
displayed and if the tooltips of truncated items are correctly positioned.
</p>
</>
);
AtEveryCorner.story = {
parameters: {
actions: {
disable: true,
},
controls: {
disable: true,
},
knobs: {
disable: true,
},
},
};
const USERS = [
'John',
'Liam',
'Olivia',
'Emma',
'Noah',
'Ava',
'Oliver',
'Elijah',
'Charlotte',
'Diego',
'Evan',
'Michael',
'Giovanni',
'Luca',
'Paolo',
'Francesca',
'Chiara',
'Sara',
'Valentina',
'Jessica',
'Angelica',
'Mario',
'Marco',
'Andrea',
'Luigi',
'Quarto',
'Quinto',
'Sesto',
'Franco',
'Sandro',
'Alehandro',
'Johnny',
'Nikole',
'Igor',
'Sipatha',
'Thami',
'Munei',
'Guilherme',
'Umair',
'Ashfaq',
'Amna',
'Irfan',
'George',
'Naseer',
'Mohammad',
'Rick',
'Saliya',
'Claire',
'Benedetta',
'Ilenia',
];
export const AsyncSelect = (
args: SelectProps & { withError: boolean; responseTime: number },
) => {
const [requests, setRequests] = useState<ReactNode[]>([]);
const fetchUserList = useCallback(
(search: string, page = 0): Promise<OptionsPromiseResult> => {
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,
};
setTimeout(() => {
resolve(result);
}, args.responseTime * 1000);
});
},
[args.responseTime],
);
async function fetchUserListError(): Promise<OptionsPromiseResult> {
return new Promise((_, reject) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject('This is an error');
});
}
return (
<>
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select
{...args}
options={args.withError ? fetchUserListError : fetchUserList}
/>
</div>
<div
style={{
position: 'absolute',
top: 32,
left: DEFAULT_WIDTH + 100,
height: 400,
width: 600,
overflowY: 'auto',
border: '1px solid #d9d9d9',
padding: 20,
}}
>
{requests.map(request => (
<p>{request}</p>
))}
</div>
</>
);
};
AsyncSelect.args = {
withError: false,
allowNewOptions: false,
paginatedFetch: false,
};
AsyncSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
},
responseTime: {
defaultValue: 1,
name: 'responseTime (seconds)',
control: {
type: 'range',
min: 1,
max: 5,
},
},
};
AsyncSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};
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

@ -39,6 +39,7 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { hasOption } from './utils';
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
type PickedSelectProps = Pick<
AntdSelectAllProps,
| 'allowClear'
@ -55,16 +56,25 @@ type PickedSelectProps = Pick<
| 'showSearch'
| 'value'
>;
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
export type OptionsPromiseResult = {
data: OptionsType;
hasMoreData: boolean;
};
export type OptionsPromise = (
search: string,
page?: number,
) => Promise<OptionsType>;
) => Promise<OptionsPromiseResult>;
export enum ESelectTypes {
MULTIPLE = 'multiple',
TAGS = 'tags',
SINGLE = '',
}
export interface SelectProps extends PickedSelectProps {
allowNewOptions?: boolean;
ariaLabel: string;
@ -75,10 +85,15 @@ export interface SelectProps extends PickedSelectProps {
paginatedFetch?: boolean;
}
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
// unexposed default behaviors
const MAX_TAG_COUNT = 4;
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
const DEBOUNCE_TIMEOUT = 800;
const DEBOUNCE_TIMEOUT = 500;
const Error = ({ error }: { error: string }) => {
const StyledError = styled.div`
@ -108,7 +123,7 @@ const DropdownContent = ({
return content;
};
const SelectComponent = ({
const Select = ({
allowNewOptions = false,
ariaLabel,
filterOption,
@ -135,6 +150,7 @@ const SelectComponent = ({
const [isLoading, setLoading] = useState(loading);
const [error, setError] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [hasMoreData, setHasMoreData] = useState(false);
const fetchRef = useRef(0);
const handleSelectMode = () => {
@ -191,9 +207,8 @@ const SelectComponent = ({
}
};
const handleFetch = useMemo(() => {
const fetchOptions = options as OptionsPromise;
const loadOptions = (value: string, paginate?: 'paginate') => {
const handleFetch = useMemo(
() => (value: string, paginate?: 'paginate') => {
if (paginate) {
fetchRef.current += 1;
} else {
@ -201,15 +216,17 @@ const SelectComponent = ({
}
const fetchId = fetchRef.current;
const page = paginatedFetch ? fetchId : undefined;
const fetchOptions = options as OptionsPromise;
fetchOptions(value, page)
.then((newOptions: OptionsType) => {
.then((result: OptionsPromiseResult) => {
const { data, hasMoreData } = result;
setHasMoreData(hasMoreData);
if (fetchId !== fetchRef.current) return;
if (newOptions && Array.isArray(newOptions) && newOptions.length) {
if (data && Array.isArray(data) && data.length) {
// merges with existing and creates unique options
setOptions(prevOptions => [
...prevOptions,
...newOptions.filter(
...data.filter(
newOpt =>
!prevOptions.find(prevOpt => prevOpt.value === newOpt.value),
),
@ -223,11 +240,11 @@ const SelectComponent = ({
}),
)
.finally(() => setLoading(false));
};
return debounce(loadOptions, DEBOUNCE_TIMEOUT);
}, [options, paginatedFetch]);
},
[options, paginatedFetch],
);
const handleOnSearch = (search: string) => {
const handleOnSearch = debounce((search: string) => {
const searchValue = search.trim();
// enables option creation
if (allowNewOptions && isSingleMode) {
@ -252,11 +269,12 @@ const SelectComponent = ({
}
}
setSearchedValue(searchValue);
};
}, DEBOUNCE_TIMEOUT);
const handlePagination = (e: UIEvent<HTMLElement>) => {
const vScroll = e.currentTarget;
if (
hasMoreData &&
isAsync &&
paginatedFetch &&
vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight
@ -310,19 +328,21 @@ const SelectComponent = ({
}
}, [allowNewOptions, isAsync, handleFetch, searchedValue]);
const dropdownRender = (
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return <DropdownContent content={originNode} error={error} />;
};
return (
<>
<StyledContainer>
{header}
<AntdSelect
aria-label={ariaLabel || name}
dropdownRender={(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return <DropdownContent content={originNode} error={error} />;
}}
dropdownRender={dropdownRender}
filterOption={handleFilterOption as any}
getPopupContainer={triggerNode => triggerNode.parentNode}
loading={isLoading}
@ -339,17 +359,11 @@ const SelectComponent = ({
showSearch={shouldShowSearch}
tokenSeparators={TOKEN_SEPARATORS}
value={selectValue}
style={{ width: '100%' }}
{...props}
/>
</>
</StyledContainer>
);
};
const Select = styled((
// eslint-disable-next-line @typescript-eslint/no-unused-vars
{ ...props }: SelectProps,
) => <SelectComponent {...props} />)`
width: 100%;
`;
export default Select;

View File

@ -23,7 +23,7 @@ import {
GroupedOptionsType,
} from 'react-select';
import { OptionsType as AntdOptionsType } from './AntdSelect';
import { OptionsType as AntdOptionsType } from './Select';
/**
* Find Option value that matches a possibly string value.

View File

@ -17,4 +17,4 @@
* under the License.
*/
export { default as Select } from './Select/AntdSelect';
export { default as Select } from './Select/Select';