Bugfix/select (#1397)

* new select styles init

* Custom select compound component

* Select component fix styles

* Added react hook form controller

* Moved from compound component

* fixed vslues & onChange for controller

* fixed tests & code cleanup

* fix review

* fixed linter

* fixed discussions

Co-authored-by: Ekaterina Petrova <epetrova@provectus.com>
This commit is contained in:
Ekaterina Petrova 2022-01-20 16:41:20 +03:00 committed by GitHub
parent 2b79fee1e4
commit 205d8d000d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 674 additions and 412 deletions

View file

@ -227,6 +227,11 @@ exports[`Connectors ListItem matches snapshot 1`] = `
},
},
"selectStyles": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#E3E6E8",
"normal": "#FFFFFF",
},
"borderColor": Object {
"active": "#454F54",
"disabled": "#E3E6E8",

View file

@ -112,6 +112,11 @@ const New: React.FC<NewProps> = ({
return null;
}
const connectOptions = connects.map(({ name: connectName }) => ({
value: connectName,
label: connectName,
}));
return (
<FormProvider {...methods}>
<PageHeading text="Create new connector" />
@ -121,13 +126,21 @@ const New: React.FC<NewProps> = ({
>
<div className={['field', connectNameFieldClassName].join(' ')}>
<InputLabel>Connect *</InputLabel>
<Select selectSize="M" name="connectName" disabled={isSubmitting}>
{connects.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select>
<Controller
control={control}
name="connectName"
render={({ field: { name, onChange } }) => (
<Select
selectSize="M"
name={name}
disabled={isSubmitting}
onChange={onChange}
value={connectOptions[0].value}
minWidth="100%"
options={connectOptions}
/>
)}
/>
<FormError>
<ErrorMessage errors={errors} name="connectName" />
</FormError>

View file

@ -77,9 +77,7 @@ const Details: React.FC = () => {
<PageHeading text={consumerGroupID}>
{!isReadOnly && (
<Dropdown label={<VerticalElipsisIcon />} right>
<DropdownItem onClick={onResetOffsets}>
Reset offsets
</DropdownItem>
<DropdownItem onClick={onResetOffsets}>Reset offset</DropdownItem>
<DropdownItem
style={{ color: Colors.red[50] }}
onClick={() => setIsConfirmationModalVisible(true)}

View file

@ -182,24 +182,47 @@ const ResetOffsets: React.FC = () => {
<form onSubmit={handleSubmit(onSubmit)}>
<MainSelectorsWrapperStyled>
<div>
<InputLabel htmlFor="topic">Topic</InputLabel>
<Select name="topic" id="topic" selectSize="M">
{uniqueTopics.map((topic) => (
<option key={topic} value={topic}>
{topic}
</option>
))}
</Select>
<InputLabel id="topicLabel">Topic</InputLabel>
<Controller
control={control}
name="topic"
render={({ field: { name, onChange, value } }) => (
<Select
id="topic"
selectSize="M"
aria-labelledby="topicLabel"
minWidth="100%"
name={name}
onChange={onChange}
value={value}
options={uniqueTopics.map((topic) => ({
value: topic,
label: topic,
}))}
/>
)}
/>
</div>
<div>
<InputLabel htmlFor="resetType">Reset Type</InputLabel>
<Select name="resetType" id="resetType" selectSize="M">
{Object.values(ConsumerGroupOffsetsResetType).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
<InputLabel id="resetTypeLabel">Reset Type</InputLabel>
<Controller
control={control}
name="resetType"
render={({ field: { name, onChange, value } }) => (
<Select
id="resetType"
selectSize="M"
aria-labelledby="resetTypeLabel"
minWidth="100%"
name={name}
onChange={onChange}
value={value}
options={Object.values(ConsumerGroupOffsetsResetType).map(
(type) => ({ value: type, label: type })
)}
/>
)}
/>
</div>
<div>
<InputLabel>Partitions</InputLabel>

View file

@ -37,7 +37,8 @@ const resetConsumerGroupOffsetsMockCalled = () =>
).toBeTruthy();
const selectresetTypeAndPartitions = async (resetType: string) => {
userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
userEvent.click(screen.getByLabelText('Reset Type'));
userEvent.click(screen.getByText(resetType));
userEvent.click(screen.getByText('Select...'));
await waitFor(() => {
userEvent.click(screen.getByText('Partition #0'));
@ -48,7 +49,9 @@ const resetConsumerGroupOffsetsWith = async (
resetType: string,
offset: null | number = null
) => {
userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
userEvent.click(screen.getByLabelText('Reset Type'));
const options = screen.getAllByText(resetType);
userEvent.click(options.length > 1 ? options[1] : options[0]);
userEvent.click(screen.getByText('Select...'));
await waitFor(() => {
userEvent.click(screen.getByText('Partition #0'));

View file

@ -41,7 +41,7 @@ describe('Details component', () => {
fetchMock.reset();
});
describe('when consumer gruops are NOT fetched', () => {
describe('when consumer groups are NOT fetched', () => {
it('renders progress bar for initial state', () => {
fetchMock.getOnce(
`/api/clusters/${clusterName}/consumer-groups/${groupId}`,
@ -73,8 +73,8 @@ describe('Details component', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('handles [Reset offsets] click', async () => {
userEvent.click(screen.getByText('Reset offsets'));
it('handles [Reset offset] click', async () => {
userEvent.click(screen.getByText('Reset offset'));
expect(history.location.pathname).toEqual(
clusterConsumerGroupResetOffsetsPath(clusterName, groupId)
);
@ -90,7 +90,7 @@ describe('Details component', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('hanles [Delete consumer group] click', async () => {
it('handles [Delete consumer group] click', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
userEvent.click(screen.getByText('Delete consumer group'));

View file

@ -85,35 +85,44 @@ const Edit: React.FC = () => {
<div>
<div>
<InputLabel>Type</InputLabel>
<Select
<Controller
control={control}
rules={{ required: true }}
name="schemaType"
required
defaultValue={schema.schemaType}
disabled={isSubmitting}
>
{Object.keys(SchemaType).map((type: string) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
render={({ field: { name, onChange } }) => (
<Select
name={name}
value={schema.schemaType}
onChange={onChange}
minWidth="100%"
disabled={isSubmitting}
options={Object.keys(SchemaType).map((type) => ({
value: type,
label: type,
}))}
/>
)}
/>
</div>
<div>
<InputLabel>Compatibility level</InputLabel>
<Select
<Controller
control={control}
name="compatibilityLevel"
defaultValue={schema.compatibilityLevel}
disabled={isSubmitting}
>
{Object.keys(CompatibilityLevelCompatibilityEnum).map(
(level: string) => (
<option key={level} value={level}>
{level}
</option>
)
render={({ field: { name, onChange } }) => (
<Select
name={name}
value={schema.compatibilityLevel}
onChange={onChange}
minWidth="100%"
disabled={isSubmitting}
options={Object.keys(
CompatibilityLevelCompatibilityEnum
).map((level) => ({ value: level, label: level }))}
/>
)}
</Select>
/>
</div>
</div>
<S.EditorsWrapper>

View file

@ -45,12 +45,8 @@ const GlobalSchemaSelector: React.FC = () => {
fetchData();
}, []);
const handleChangeCompatibilityLevel = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
setNextCompatibilityLevel(
event.target.value as CompatibilityLevelCompatibilityEnum
);
const handleChangeCompatibilityLevel = (level: string | number) => {
setNextCompatibilityLevel(level as CompatibilityLevelCompatibilityEnum);
setIsConfirmationVisible(true);
};
@ -62,10 +58,10 @@ const GlobalSchemaSelector: React.FC = () => {
clusterName,
compatibilityLevel: { compatibility: nextCompatibilityLevel },
});
dispatch(fetchSchemas(clusterName));
setCurrentCompatibilityLevel(nextCompatibilityLevel);
setNextCompatibilityLevel(undefined);
setIsConfirmationVisible(false);
dispatch(fetchSchemas(clusterName));
} catch (e) {
const err = await getResponse(e as Response);
dispatch(serverErrorAlertAdded(err));
@ -81,18 +77,14 @@ const GlobalSchemaSelector: React.FC = () => {
<div>Global Compatibility Level: </div>
<Select
selectSize="M"
value={currentCompatibilityLevel}
defaultValue={currentCompatibilityLevel}
minWidth="200px"
onChange={handleChangeCompatibilityLevel}
disabled={isFetching || isUpdating || isConfirmationVisible}
>
{Object.keys(CompatibilityLevelCompatibilityEnum).map(
(level: string) => (
<option key={level} value={level}>
{level}
</option>
)
options={Object.keys(CompatibilityLevelCompatibilityEnum).map(
(level) => ({ value: level, label: level })
)}
</Select>
/>
<ConfirmationModal
isOpen={isConfirmationVisible}
onCancel={() => setIsConfirmationVisible(false)}

View file

@ -1,5 +1,5 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import { CompatibilityLevelCompatibilityEnum } from 'generated-sources';
import GlobalSchemaSelector from 'components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector';
@ -10,15 +10,20 @@ import fetchMock from 'fetch-mock';
const clusterName = 'testClusterName';
const selectForwardOption = () =>
userEvent.selectOptions(
screen.getByRole('listbox'),
CompatibilityLevelCompatibilityEnum.FORWARD
const selectForwardOption = () => {
const dropdownElement = screen.getByRole('listbox');
// clicks to open dropdown
userEvent.click(within(dropdownElement).getByRole('option'));
userEvent.click(
screen.getByText(CompatibilityLevelCompatibilityEnum.FORWARD)
);
};
const expectOptionIsSelected = (option: string) => {
const optionElement: HTMLOptionElement = screen.getByText(option);
expect(optionElement.selected).toBeTruthy();
const dropdownElement = screen.getByRole('listbox');
const selectedOption = within(dropdownElement).getAllByRole('option');
expect(selectedOption.length).toEqual(1);
expect(selectedOption[0]).toHaveTextContent(option);
};
describe('GlobalSchemaSelector', () => {

View file

@ -1,6 +1,6 @@
import React from 'react';
import { NewSchemaSubjectRaw } from 'redux/interfaces';
import { FormProvider, useForm } from 'react-hook-form';
import { FormProvider, useForm, Controller } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import { clusterSchemaPath } from 'lib/paths';
import { SchemaType } from 'generated-sources';
@ -9,7 +9,7 @@ import { useHistory, useParams } from 'react-router';
import { InputLabel } from 'components/common/Input/InputLabel.styled';
import Input from 'components/common/Input/Input';
import { FormError } from 'components/common/Input/Input.styled';
import Select from 'components/common/Select/Select';
import Select, { SelectOption } from 'components/common/Select/Select';
import { Button } from 'components/common/Button/Button';
import { Textarea } from 'components/common/Textbox/Textarea.styled';
import PageHeading from 'components/common/PageHeading/PageHeading';
@ -23,6 +23,12 @@ import { getResponse } from 'lib/errorHandling';
import * as S from './New.styled';
const SchemaTypeOptions: Array<SelectOption> = [
{ value: SchemaType.AVRO, label: 'AVRO' },
{ value: SchemaType.JSON, label: 'JSON' },
{ value: SchemaType.PROTOBUF, label: 'PROTOBUF' },
];
const New: React.FC = () => {
const { clusterName } = useParams<{ clusterName: string }>();
const history = useHistory();
@ -31,6 +37,7 @@ const New: React.FC = () => {
const {
register,
handleSubmit,
control,
formState: { isDirty, isSubmitting, errors },
} = methods;
@ -91,18 +98,22 @@ const New: React.FC = () => {
<div>
<InputLabel>Schema Type *</InputLabel>
<Select
selectSize="M"
<Controller
control={control}
rules={{ required: 'Schema Type is required.' }}
name="schemaType"
hookFormOptions={{
required: 'Schema Type is required.',
}}
disabled={isSubmitting}
>
<option value={SchemaType.AVRO}>AVRO</option>
<option value={SchemaType.JSON}>JSON</option>
<option value={SchemaType.PROTOBUF}>PROTOBUF</option>
</Select>
render={({ field: { name, onChange } }) => (
<Select
selectSize="M"
name={name}
value={SchemaTypeOptions[0].value}
onChange={onChange}
minWidth="50%"
disabled={isSubmitting}
options={SchemaTypeOptions}
/>
)}
/>
<FormError>
<ErrorMessage errors={errors} name="schemaType" />
</FormError>

View file

@ -11,10 +11,6 @@ export const FiltersWrapper = styled.div`
display: flex;
justify-content: space-between;
padding-top: 16px;
& > div:last-child {
width: 10%;
}
}
`;

View file

@ -48,6 +48,15 @@ export interface FiltersProps {
const PER_PAGE = 100;
const SeekTypeOptions = [
{ value: SeekType.OFFSET, label: 'Offset' },
{ value: SeekType.TIMESTAMP, label: 'Timestamp' },
];
const SeekDirectionOptions = [
{ value: SeekDirection.FORWARD, label: 'Oldest First' },
{ value: SeekDirection.BACKWARD, label: 'Newest First' },
];
const Filters: React.FC<FiltersProps> = ({
clusterName,
topicName,
@ -76,7 +85,7 @@ const Filters: React.FC<FiltersProps> = ({
);
const [attempt, setAttempt] = React.useState(0);
const [seekType, setSeekType] = React.useState<SeekType>(
const [currentSeekType, setCurrentSeekType] = React.useState<SeekType>(
(searchParams.get('seekType') as SeekType) || SeekType.OFFSET
);
const [offset, setOffset] = React.useState<string>(
@ -99,11 +108,11 @@ const Filters: React.FC<FiltersProps> = ({
const isSubmitDisabled = React.useMemo(() => {
if (isSeekTypeControlVisible) {
return seekType === SeekType.TIMESTAMP && !timestamp;
return currentSeekType === SeekType.TIMESTAMP && !timestamp;
}
return false;
}, [isSeekTypeControlVisible, seekType, timestamp]);
}, [isSeekTypeControlVisible, currentSeekType, timestamp]);
const partitionMap = React.useMemo(
() =>
@ -128,11 +137,11 @@ const Filters: React.FC<FiltersProps> = ({
};
if (isSeekTypeControlVisible) {
props.seekType = seekType;
props.seekType = currentSeekType;
props.seekTo = selectedPartitions.map(({ value }) => {
let seekToOffset;
if (seekType === SeekType.OFFSET) {
if (currentSeekType === SeekType.OFFSET) {
if (offset) {
seekToOffset = offset;
} else {
@ -244,16 +253,13 @@ const Filters: React.FC<FiltersProps> = ({
<S.SeekTypeSelectorWrapper>
<Select
id="selectSeekType"
onChange={({ target: { value } }) =>
setSeekType(value as SeekType)
}
value={seekType}
onChange={(option) => setCurrentSeekType(option as SeekType)}
value={currentSeekType}
selectSize="M"
>
<option value={SeekType.OFFSET}>Offset</option>
<option value={SeekType.TIMESTAMP}>Timestamp</option>
</Select>
{seekType === SeekType.OFFSET ? (
minWidth="100px"
options={SeekTypeOptions}
/>
{currentSeekType === SeekType.OFFSET ? (
<Input
id="offset"
type="text"
@ -311,13 +317,11 @@ const Filters: React.FC<FiltersProps> = ({
</S.FilterInputs>
<Select
selectSize="M"
onChange={(e) => toggleSeekDirection(e.target.value)}
onChange={(option) => toggleSeekDirection(option as string)}
value={seekDirection}
minWidth="120px"
>
<option value={SeekDirection.FORWARD}>Oldest First</option>
<option value={SeekDirection.BACKWARD}>Newest First</option>
</Select>
options={SeekDirectionOptions}
/>
</div>
<S.FiltersMetrics>
<p style={{ fontSize: 14 }}>{isFetching && phaseMessage}</p>

View file

@ -181,48 +181,69 @@ exports[`Filters component matches the snapshot 1`] = `
position: relative;
}
.c7 {
.c6 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 32px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
min-width: auto;
min-width: 100px;
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c7:hover {
.c6:hover {
color: #171A1C;
border-color: #73848C;
}
.c7:focus {
.c6:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c7:disabled {
.c6:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c11 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 32px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
@ -231,9 +252,6 @@ exports[`Filters component matches the snapshot 1`] = `
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c11:hover {
@ -253,8 +271,12 @@ exports[`Filters component matches the snapshot 1`] = `
cursor: not-allowed;
}
.c6 {
position: relative;
.c7 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c10 {
@ -332,10 +354,6 @@ exports[`Filters component matches the snapshot 1`] = `
padding-top: 16px;
}
.c0 > div:first-child > div:last-child {
width: 10%;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
@ -458,25 +476,19 @@ exports[`Filters component matches the snapshot 1`] = `
<div
class="c5"
>
<div
class="select-wrapper c6"
>
<select
class="c7"
id="selectSeekType"
<div>
<ul
class="c6"
role="listbox"
>
<option
value="OFFSET"
<li
class="c7"
role="option"
tabindex="0"
>
Offset
</option>
<option
value="TIMESTAMP"
>
Timestamp
</option>
</select>
</li>
</ul>
</div>
<div
class="c2 offset-selector"
@ -558,24 +570,19 @@ exports[`Filters component matches the snapshot 1`] = `
Submit
</button>
</div>
<div
class="select-wrapper c6"
>
<select
<div>
<ul
class="c11"
role="listbox"
>
<option
value="FORWARD"
<li
class="c7"
role="option"
tabindex="0"
>
Oldest First
</option>
<option
value="BACKWARD"
>
Newest First
</option>
</select>
</li>
</ul>
</div>
</div>
<div
@ -818,48 +825,69 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
position: relative;
}
.c7 {
.c6 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 32px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
min-width: auto;
min-width: 100px;
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c7:hover {
.c6:hover {
color: #171A1C;
border-color: #73848C;
}
.c7:focus {
.c6:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c7:disabled {
.c6:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c11 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 32px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
@ -868,9 +896,6 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c11:hover {
@ -890,8 +915,12 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
cursor: not-allowed;
}
.c6 {
position: relative;
.c7 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c10 {
@ -969,10 +998,6 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
padding-top: 16px;
}
.c0 > div:first-child > div:last-child {
width: 10%;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
@ -1094,25 +1119,19 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
<div
class="c5"
>
<div
class="select-wrapper c6"
>
<select
class="c7"
id="selectSeekType"
<div>
<ul
class="c6"
role="listbox"
>
<option
value="OFFSET"
<li
class="c7"
role="option"
tabindex="0"
>
Offset
</option>
<option
value="TIMESTAMP"
>
Timestamp
</option>
</select>
</li>
</ul>
</div>
<div
class="c2 offset-selector"
@ -1194,24 +1213,19 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
Cancel
</button>
</div>
<div
class="select-wrapper c6"
>
<select
<div>
<ul
class="c11"
role="listbox"
>
<option
value="FORWARD"
<li
class="c7"
role="option"
tabindex="0"
>
Oldest First
</option>
<option
value="BACKWARD"
>
Newest First
</option>
</select>
</li>
</ul>
</div>
</div>
<div

View file

@ -238,6 +238,11 @@ exports[`Details when it has readonly flag does not render the Action button a T
},
},
"selectStyles": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#E3E6E8",
"normal": "#FFFFFF",
},
"borderColor": Object {
"active": "#454F54",
"disabled": "#E3E6E8",

View file

@ -1,7 +1,7 @@
import React, { useRef } from 'react';
import { ErrorMessage } from '@hookform/error-message';
import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
import { FieldArrayWithId, useFormContext } from 'react-hook-form';
import { FieldArrayWithId, useFormContext, Controller } from 'react-hook-form';
import { TopicFormData } from 'redux/interfaces';
import { InputLabel } from 'components/common/Input/InputLabel.styled';
import { FormError } from 'components/common/Input/Input.styled';
@ -34,6 +34,7 @@ const CustomParamField: React.FC<Props> = ({
formState: { errors },
setValue,
watch,
control,
} = useFormContext<TopicFormData>();
const nameValue = watch(`customParams.${index}.name`);
const prevName = useRef(nameValue);
@ -49,7 +50,9 @@ const CustomParamField: React.FC<Props> = ({
prevName.current = nameValue;
newExistingFields.push(nameValue);
setExistingFields(newExistingFields);
setValue(`customParams.${index}.value`, TOPIC_CUSTOM_PARAMS[nameValue]);
setValue(`customParams.${index}.value`, TOPIC_CUSTOM_PARAMS[nameValue], {
shouldValidate: true,
});
}
}, [nameValue]);
@ -58,27 +61,27 @@ const CustomParamField: React.FC<Props> = ({
<>
<div>
<InputLabel>Custom Parameter</InputLabel>
<Select
name={`customParams.${index}.name` as const}
hookFormOptions={{
required: 'Custom Parameter is required.',
}}
disabled={isDisabled}
defaultValue={field.name}
>
<option value="">Select</option>
{Object.keys(TOPIC_CUSTOM_PARAMS)
.sort()
.map((opt) => (
<option
key={opt}
value={opt}
disabled={existingFields.includes(opt)}
>
{opt}
</option>
))}
</Select>
<Controller
control={control}
rules={{ required: 'Custom Parameter is required.' }}
name={`customParams.${index}.name`}
render={({ field: { name, onChange } }) => (
<Select
name={name}
placeholder="Select"
disabled={isDisabled}
minWidth="270px"
onChange={onChange}
options={Object.keys(TOPIC_CUSTOM_PARAMS)
.sort()
.map((opt) => ({
value: opt,
label: opt,
disabled: existingFields.includes(opt),
}))}
/>
)}
/>
<FormError>
<ErrorMessage
errors={errors}

View file

@ -1,5 +1,5 @@
import React from 'react';
import { screen, within } from '@testing-library/react';
import {screen, waitFor, within} from '@testing-library/react';
import { render } from 'lib/testHelpers';
import CustomParamsField, {
Props,
@ -17,6 +17,11 @@ const setExistingFields = jest.fn();
const SPACE_KEY = ' ';
const selectOption = async (listbox: HTMLElement, option: string) => {
await waitFor(() => userEvent.click(listbox));
await waitFor(() => userEvent.click(screen.getByText(option)));
};
describe('CustomParamsField', () => {
const setupComponent = (props: Props) => {
const Wrapper: React.FC = ({ children }) => {
@ -73,7 +78,7 @@ describe('CustomParamsField', () => {
expect(remove.mock.calls.length).toBe(2);
});
it('can select option', () => {
it('can select option', async () => {
setupComponent({
field,
isDisabled,
@ -83,13 +88,14 @@ describe('CustomParamsField', () => {
setExistingFields,
});
const listbox = screen.getByRole('listbox');
userEvent.selectOptions(listbox, ['compression.type']);
await selectOption(listbox, 'compression.type');
const option = within(listbox).getByRole('option', { selected: true });
expect(option).toHaveValue('compression.type');
const selectedOption = within(listbox).getAllByRole('option');
expect(selectedOption.length).toEqual(1);
expect(selectedOption[0]).toHaveTextContent('compression.type');
});
it('selecting option updates textbox value', () => {
it('selecting option updates textbox value', async () => {
setupComponent({
field,
isDisabled,
@ -99,13 +105,13 @@ describe('CustomParamsField', () => {
setExistingFields,
});
const listbox = screen.getByRole('listbox');
userEvent.selectOptions(listbox, ['compression.type']);
await selectOption(listbox, 'compression.type');
const textbox = screen.getByRole('textbox');
expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
});
it('selecting option updates triggers setExistingFields', () => {
it('selecting option updates triggers setExistingFields', async () => {
setupComponent({
field,
isDisabled,
@ -115,7 +121,7 @@ describe('CustomParamsField', () => {
setExistingFields,
});
const listbox = screen.getByRole('listbox');
userEvent.selectOptions(listbox, ['compression.type']);
await selectOption(listbox, 'compression.type');
expect(setExistingFields.mock.calls.length).toBe(1);
});

View file

@ -1,5 +1,5 @@
import React from 'react';
import { screen, within } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import CustomParams, {
CustomParamsProps,
@ -8,6 +8,30 @@ import { FormProvider, useForm } from 'react-hook-form';
import userEvent from '@testing-library/user-event';
import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
const selectOption = async (listbox: HTMLElement, option: string) => {
await waitFor(() => userEvent.click(listbox));
await waitFor(() => userEvent.click(screen.getByText(option)));
};
const expectOptionIsSelected = (listbox: HTMLElement, option: string) => {
const selectedOption = within(listbox).getAllByRole('option');
expect(selectedOption.length).toEqual(1);
expect(selectedOption[0]).toHaveTextContent(option);
};
const expectOptionIsDisabled = async (
listbox: HTMLElement,
option: string,
disabled: boolean
) => {
await waitFor(() => userEvent.click(listbox));
const selectedOption = within(listbox).getAllByText(option);
expect(selectedOption[1]).toHaveStyleRule(
'cursor',
disabled ? 'not-allowed' : 'pointer'
);
};
describe('CustomParams', () => {
const setupComponent = (props: CustomParamsProps) => {
const Wrapper: React.FC = ({ children }) => {
@ -33,9 +57,9 @@ describe('CustomParams', () => {
});
describe('works with user inputs correctly', () => {
it('button click creates custom param fieldset', () => {
it('button click creates custom param fieldset', async () => {
const addParamButton = screen.getByRole('button');
userEvent.click(addParamButton);
await waitFor(() => userEvent.click(addParamButton));
const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
@ -44,51 +68,39 @@ describe('CustomParams', () => {
expect(textbox).toBeInTheDocument();
});
it('can select option', () => {
it('can select option', async () => {
const addParamButton = screen.getByRole('button');
userEvent.click(addParamButton);
await waitFor(() => userEvent.click(addParamButton));
const listbox = screen.getByRole('listbox');
userEvent.selectOptions(listbox, ['compression.type']);
const option = screen.getByRole('option', {
selected: true,
});
expect(option).toHaveValue('compression.type');
expect(option).toBeDisabled();
await selectOption(listbox, 'compression.type');
expectOptionIsSelected(listbox, 'compression.type');
expectOptionIsDisabled(listbox, 'compression.type', true);
const textbox = screen.getByRole('textbox');
expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
});
it('when selected option changes disabled options update correctly', () => {
it('when selected option changes disabled options update correctly', async () => {
const addParamButton = screen.getByRole('button');
userEvent.click(addParamButton);
await waitFor(() => userEvent.click(addParamButton));
const listbox = screen.getByRole('listbox');
userEvent.selectOptions(listbox, ['compression.type']);
await selectOption(listbox, 'compression.type');
expectOptionIsDisabled(listbox, 'compression.type', true);
const option = screen.getByRole('option', {
name: 'compression.type',
});
expect(option).toBeDisabled();
userEvent.selectOptions(listbox, ['delete.retention.ms']);
const newOption = screen.getByRole('option', {
name: 'delete.retention.ms',
});
expect(newOption).toBeDisabled();
expect(option).toBeEnabled();
await selectOption(listbox, 'delete.retention.ms');
expectOptionIsDisabled(listbox, 'delete.retention.ms', true);
expectOptionIsDisabled(listbox, 'compression.type', false);
});
it('multiple button clicks create multiple fieldsets', () => {
it('multiple button clicks create multiple fieldsets', async () => {
const addParamButton = screen.getByRole('button');
userEvent.click(addParamButton);
userEvent.click(addParamButton);
userEvent.click(addParamButton);
await waitFor(() => userEvent.click(addParamButton));
await waitFor(() => userEvent.click(addParamButton));
await waitFor(() => userEvent.click(addParamButton));
const listboxes = screen.getAllByRole('listbox');
expect(listboxes.length).toBe(3);
@ -97,7 +109,7 @@ describe('CustomParams', () => {
expect(textboxes.length).toBe(3);
});
it("can't select already selected option", () => {
it("can't select already selected option", async () => {
const addParamButton = screen.getByRole('button');
userEvent.click(addParamButton);
userEvent.click(addParamButton);
@ -105,18 +117,11 @@ describe('CustomParams', () => {
const listboxes = screen.getAllByRole('listbox');
const firstListbox = listboxes[0];
userEvent.selectOptions(firstListbox, ['compression.type']);
const firstListboxOption = within(firstListbox).getByRole('option', {
selected: true,
});
expect(firstListboxOption).toBeDisabled();
await selectOption(firstListbox, 'compression.type');
expectOptionIsDisabled(firstListbox, 'compression.type', true);
const secondListbox = listboxes[1];
const secondListboxOption = within(secondListbox).getByRole('option', {
name: 'compression.type',
});
expect(secondListboxOption).toBeDisabled();
expectOptionIsDisabled(secondListbox, 'compression.type', true);
});
it('when fieldset with selected custom property type is deleted disabled options update correctly', async () => {
@ -128,26 +133,16 @@ describe('CustomParams', () => {
const listboxes = screen.getAllByRole('listbox');
const firstListbox = listboxes[0];
userEvent.selectOptions(firstListbox, ['compression.type']);
const firstListboxOption = within(firstListbox).getByRole('option', {
selected: true,
});
expect(firstListboxOption).toBeDisabled();
await selectOption(firstListbox, 'compression.type');
expectOptionIsDisabled(firstListbox, 'compression.type', true);
const secondListbox = listboxes[1];
userEvent.selectOptions(secondListbox, ['delete.retention.ms']);
const secondListboxOption = within(secondListbox).getByRole('option', {
selected: true,
});
expect(secondListboxOption).toBeDisabled();
await selectOption(secondListbox, 'delete.retention.ms');
expectOptionIsDisabled(secondListbox, 'delete.retention.ms', true);
const thirdListbox = listboxes[2];
userEvent.selectOptions(thirdListbox, ['file.delete.delay.ms']);
const thirdListboxOption = within(thirdListbox).getByRole('option', {
selected: true,
});
expect(thirdListboxOption).toBeDisabled();
await selectOption(thirdListbox, 'file.delete.delay.ms');
expectOptionIsDisabled(thirdListbox, 'file.delete.delay.ms', true);
const deleteSecondFieldsetButton = screen.getByTitle(
'Delete customParam field 1'
@ -155,17 +150,8 @@ describe('CustomParams', () => {
userEvent.click(deleteSecondFieldsetButton);
expect(secondListbox).not.toBeInTheDocument();
expect(
within(firstListbox).getByRole('option', {
name: 'delete.retention.ms',
})
).toBeEnabled();
expect(
within(thirdListbox).getByRole('option', {
name: 'delete.retention.ms',
})
).toBeEnabled();
expectOptionIsDisabled(firstListbox, 'delete.retention.ms', false);
expectOptionIsDisabled(thirdListbox, 'delete.retention.ms', false);
});
});
});

View file

@ -1,14 +1,14 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import { BYTES_IN_GB } from 'lib/constants';
import { TopicName, TopicConfigByName } from 'redux/interfaces';
import { ErrorMessage } from '@hookform/error-message';
import Select from 'components/common/Select/Select';
import Select, { SelectOption } from 'components/common/Select/Select';
import Input from 'components/common/Input/Input';
import { Button } from 'components/common/Button/Button';
import { InputLabel } from 'components/common/Input/InputLabel.styled';
import { FormError } from 'components/common/Input/Input.styled';
import { StyledForm } from 'components/common/Form/Form.styles';
import { StyledForm } from 'components/common/Form/Form.styled';
import CustomParamsContainer from './CustomParams/CustomParamsContainer';
import TimeToRetain from './TimeToRetain';
@ -22,6 +22,20 @@ interface Props {
onSubmit: (e: React.BaseSyntheticEvent) => Promise<void>;
}
const CleanupPolicyOptions: Array<SelectOption> = [
{ value: 'delete', label: 'Delete' },
{ value: 'compact', label: 'Compact' },
{ value: 'compact,delete', label: 'Compact,Delete' },
];
const RetentionBytesOptions: Array<SelectOption> = [
{ value: -1, label: 'Not Set' },
{ value: BYTES_IN_GB, label: '1 GB' },
{ value: BYTES_IN_GB * 10, label: '10 GB' },
{ value: BYTES_IN_GB * 20, label: '20 GB' },
{ value: BYTES_IN_GB * 50, label: '50 GB' },
];
const TopicForm: React.FC<Props> = ({
topicName,
config,
@ -30,6 +44,7 @@ const TopicForm: React.FC<Props> = ({
onSubmit,
}) => {
const {
control,
formState: { errors },
} = useFormContext();
@ -99,11 +114,19 @@ const TopicForm: React.FC<Props> = ({
</div>
<div>
<InputLabel>Cleanup policy</InputLabel>
<Select defaultValue="delete" name="cleanupPolicy" minWidth="250px">
<option value="delete">Delete</option>
<option value="compact">Compact</option>
<option value="compact,delete">Compact,Delete</option>
</Select>
<Controller
control={control}
name="cleanupPolicy"
render={({ field: { name, onChange } }) => (
<Select
name={name}
value={CleanupPolicyOptions[0].value}
onChange={onChange}
minWidth="250px"
options={CleanupPolicyOptions}
/>
)}
/>
</div>
</S.Column>
@ -116,13 +139,19 @@ const TopicForm: React.FC<Props> = ({
<S.Column>
<div>
<InputLabel>Max size on disk in GB</InputLabel>
<Select defaultValue={-1} name="retentionBytes">
<option value={-1}>Not Set</option>
<option value={BYTES_IN_GB}>1 GB</option>
<option value={BYTES_IN_GB * 10}>10 GB</option>
<option value={BYTES_IN_GB * 20}>20 GB</option>
<option value={BYTES_IN_GB * 50}>50 GB</option>
</Select>
<Controller
control={control}
name="retentionBytes"
render={({ field: { name, onChange } }) => (
<Select
name={name}
value={RetentionBytesOptions[0].value}
onChange={onChange}
minWidth="100%"
options={RetentionBytesOptions}
/>
)}
/>
</div>
<div>

View file

@ -5,9 +5,9 @@ interface Props {
className?: string;
}
const LiveIcon: React.FC<Props> = ({ className }) => {
const LiveIcon: React.FC<Props> = () => {
return (
<i className={className}>
<i>
<svg
width="16"
height="16"

View file

@ -4,23 +4,42 @@ interface Props {
selectSize: 'M' | 'L';
isLive?: boolean;
minWidth?: string;
disabled?: boolean;
}
export const Select = styled.select<Props>`
interface OptionProps {
disabled?: boolean;
}
export const Select = styled.ul<Props>`
position: relative;
list-style: none;
display: flex;
align-items: center;
height: ${(props) => (props.selectSize === 'M' ? '32px' : '40px')};
border: 1px ${(props) => props.theme.selectStyles.borderColor.normal} solid;
border: 1px
${({ theme, disabled }) =>
disabled
? theme.selectStyles.borderColor.disabled
: theme.selectStyles.borderColor.normal}
solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: fit-content;
padding-left: ${(props) => (props.isLive ? '36px' : '12px')};
padding-right: 16px;
color: ${(props) => props.theme.selectStyles.color.normal};
color: ${({ theme, disabled }) =>
disabled
? theme.selectStyles.color.disabled
: theme.selectStyles.color.normal};
min-width: ${({ minWidth }) => minWidth || 'auto'};
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
background-image: ${({ disabled }) =>
`url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="${
disabled ? '%23ABB5BA' : '%23454F54'
}"/%3E%3C/svg%3E%0A') !important`};
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
appearance: none !important;
&:hover {
color: ${(props) => props.theme.selectStyles.color.hover};
@ -37,3 +56,46 @@ export const Select = styled.select<Props>`
cursor: not-allowed;
}
`;
export const OptionList = styled.ul`
position: absolute;
top: 100%;
left: 0;
max-height: 114px;
margin-top: 4px;
background-color: ${(props) =>
props.theme.selectStyles.backgroundColor.normal};
border: 1px ${(props) => props.theme.selectStyles.borderColor.normal} solid;
border-radius: 4px;
font-size: 14px;
line-height: 18px;
width: 100%;
color: ${(props) => props.theme.selectStyles.color.normal};
overflow-y: scroll;
z-index: 10;
`;
export const Option = styled.li<OptionProps>`
list-style: none;
padding: 10px 12px;
transition: all 0.2s ease-in-out;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
&:hover {
background-color: ${(props) =>
props.theme.selectStyles.backgroundColor.hover};
}
&:active {
background-color: ${(props) =>
props.theme.selectStyles.backgroundColor.active};
}
`;
export const SelectedOption = styled.li`
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

View file

@ -1,56 +1,96 @@
import styled from 'styled-components';
import React from 'react';
import { RegisterOptions, useFormContext } from 'react-hook-form';
import React, { useState, useRef } from 'react';
import useClickOutside from 'lib/hooks/useClickOutside';
import LiveIcon from './LiveIcon.styled';
import * as S from './Select.styled';
import LiveIcon from './LiveIcon.styled';
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {
export interface SelectProps {
options?: Array<SelectOption>;
id?: string;
name?: string;
selectSize?: 'M' | 'L';
isLive?: boolean;
hookFormOptions?: RegisterOptions;
minWidth?: string;
value?: string | number;
defaultValue?: string | number;
placeholder?: string;
disabled?: boolean;
onChange?: (option: string | number) => void;
}
export interface SelectOption {
label: string | number;
value: string | number;
disabled?: boolean;
}
const Select: React.FC<SelectProps> = ({
className,
children,
id,
options = [],
value,
defaultValue,
selectSize = 'L',
placeholder = '',
isLive,
name,
hookFormOptions,
disabled = false,
onChange,
...props
}) => {
const methods = useFormContext();
const [selectedOption, setSelectedOption] = useState(value);
const [showOptions, setShowOptions] = useState(false);
const showOptionsHandler = () => {
if (!disabled) setShowOptions(!showOptions);
};
const selectContainerRef = useRef(null);
const clickOutsideHandler = () => setShowOptions(false);
useClickOutside(selectContainerRef, clickOutsideHandler);
const updateSelectedOption = (option: SelectOption) => {
if (disabled) return;
setSelectedOption(option.value);
if (onChange) onChange(option.value);
setShowOptions(false);
};
return (
<div className={`select-wrapper ${className}`}>
<div ref={selectContainerRef}>
{isLive && <LiveIcon />}
{name ? (
<S.Select
role="listbox"
selectSize={selectSize}
isLive={isLive}
{...methods.register(name, { ...hookFormOptions })}
{...props}
>
{children}
</S.Select>
) : (
<S.Select
role="listbox"
selectSize={selectSize}
isLive={isLive}
{...props}
>
{children}
</S.Select>
)}
<S.Select
role="listbox"
selectSize={selectSize}
isLive={isLive}
disabled={disabled}
onClick={showOptionsHandler}
onKeyDown={showOptionsHandler}
{...props}
>
<S.SelectedOption role="option" tabIndex={0}>
{options.find(
(option) => option.value === (defaultValue || selectedOption)
)?.label || placeholder}
</S.SelectedOption>
{showOptions && (
<S.OptionList>
{options?.map((option) => (
<S.Option
value={option.value}
key={option.value}
disabled={option.disabled}
onClick={() => updateSelectedOption(option)}
tabIndex={0}
role="option"
>
{option.label}
</S.Option>
))}
</S.OptionList>
)}
</S.Select>
</div>
);
};
export default styled(Select)`
position: relative;
`;
export default Select;

View file

@ -2,22 +2,24 @@
exports[`Custom Select when live matches the snapshot 1`] = `
<body>
.c1 {
position: absolute;
left: 12px;
top: 50%;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
line-height: 0;
}
.c2 {
.c0 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 40px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 36px;
padding-right: 16px;
color: #171A1C;
@ -26,39 +28,36 @@ exports[`Custom Select when live matches the snapshot 1`] = `
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c2:hover {
.c0:hover {
color: #171A1C;
border-color: #73848C;
}
.c2:focus {
.c0:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c2:disabled {
.c0:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c0 {
position: relative;
.c1 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<div>
<div
class="select-wrapper c0"
>
<i
class="c1"
>
<div>
<i>
<svg
fill="none"
height="16"
@ -80,22 +79,41 @@ exports[`Custom Select when live matches the snapshot 1`] = `
/>
</svg>
</i>
<select
class="c2"
<ul
class="c0"
name="test"
role="listbox"
/>
>
<li
class="c1"
role="option"
tabindex="0"
/>
</ul>
</div>
</div>
</body>
`;
exports[`Custom Select when non-live matches the snapshot 1`] = `
.c1 {
.c0 {
position: relative;
list-style: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 40px;
border: 1px #ABB5BA solid;
border-radius: 4px;
font-size: 14px;
width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
@ -104,41 +122,47 @@ exports[`Custom Select when non-live matches the snapshot 1`] = `
background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
appearance: none !important;
}
.c1:hover {
.c0:hover {
color: #171A1C;
border-color: #73848C;
}
.c1:focus {
.c0:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c1:disabled {
.c0:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c0 {
position: relative;
.c1 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<body>
<div>
<div
class="select-wrapper c0"
>
<select
class="c1"
<div>
<ul
class="c0"
name="test"
role="listbox"
/>
>
<li
class="c1"
role="option"
tabindex="0"
/>
</ul>
</div>
</div>
</body>

View file

@ -0,0 +1,29 @@
import { RefObject, useEffect } from 'react';
type Event = MouseEvent | TouchEvent;
const useClickOutside = <T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
handler: (event: Event) => void
) => {
useEffect(() => {
const listener = (event: Event) => {
const el = ref?.current;
if (!el || el.contains((event?.target as Node) || null)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
};
export default useClickOutside;

View file

@ -165,6 +165,11 @@ const theme = {
},
},
selectStyles: {
backgroundColor: {
normal: Colors.neutral[0],
hover: Colors.neutral[10],
active: Colors.neutral[10],
},
color: {
normal: Colors.neutral[90],
hover: Colors.neutral[90],