Skip to content

Commit b5fe859

Browse files
feat: add dropdown component (#200)
1 parent 5633dfc commit b5fe859

File tree

8 files changed

+507
-0
lines changed

8 files changed

+507
-0
lines changed
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { screen } from '@testing-library/dom';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithTheme } from '../utils/test-utils';
4+
import { DropdownProps, IonDropdown } from './dropdown';
5+
6+
const options = [
7+
{ label: 'Item 1', value: '1' },
8+
{ label: 'Item 2', value: '2' },
9+
{ label: 'Item 3', value: '3' },
10+
{ label: 'Item 4', value: '4' },
11+
{ label: 'Item 5', value: '5' },
12+
{ label: 'Item 6', value: '6' },
13+
{ label: 'Item 7', value: '7' },
14+
];
15+
16+
const sut = (props: DropdownProps) => {
17+
renderWithTheme(<IonDropdown {...props} />);
18+
};
19+
20+
describe('Dropdown', () => {
21+
it('should render a list of items', () => {
22+
sut({ options });
23+
options.forEach((option) => {
24+
expect(screen.getByText(option.label)).toBeInTheDocument();
25+
});
26+
});
27+
it('should emit a click with a value', async () => {
28+
const onItemSelect = jest.fn();
29+
sut({ options, onItemSelect });
30+
await userEvent.click(screen.getByText(options[0].label));
31+
expect(onItemSelect).toHaveBeenCalledWith(options[0].value);
32+
});
33+
it('should render an empty message when there is no options', () => {
34+
sut({} as DropdownProps);
35+
expect(screen.getByText('Não há dados')).toBeInTheDocument();
36+
});
37+
it('should not emit a click when there is no onItemSelect', async () => {
38+
sut({ options, onItemSelect: undefined });
39+
await userEvent.click(screen.getByText(options[0].label));
40+
expect(screen.getByText(options[0].label)).toBeInTheDocument();
41+
});
42+
it('should render a loading spinner', () => {
43+
sut({ options: [], loading: true });
44+
expect(screen.getByTestId('ion-spinner')).toBeInTheDocument();
45+
});
46+
it('should render a top container', () => {
47+
const testId = 'top-container';
48+
sut({
49+
options,
50+
topContainer: <div data-testid={testId}>Top Container</div>,
51+
});
52+
expect(screen.getByTestId(testId)).toBeInTheDocument();
53+
});
54+
it('should render a bottom container', () => {
55+
const testId = 'bottom-container';
56+
sut({
57+
options,
58+
bottomContainer: <div data-testid={testId}>Bottom Container</div>,
59+
});
60+
expect(screen.getByTestId(testId)).toBeInTheDocument();
61+
});
62+
});

src/components/dropdown/dropdown.tsx

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { IonEmpty } from '../empty';
2+
import { IonSpinner } from '../spinner';
3+
import {
4+
DropdownItemProps,
5+
IonDropdownItem,
6+
} from './dropdownItem/dropdownItem';
7+
import { Container, Dropdown, ItemsContainer } from './styles';
8+
9+
interface Option extends Omit<DropdownItemProps, 'onClick'> {
10+
value: string;
11+
}
12+
13+
export interface DropdownProps {
14+
options: Option[];
15+
onItemSelect?: (value: Option['value']) => void;
16+
loading?: boolean;
17+
topContainer?: React.ReactNode;
18+
bottomContainer?: React.ReactNode;
19+
}
20+
21+
export const IonDropdown = ({
22+
options = [],
23+
onItemSelect,
24+
loading,
25+
topContainer,
26+
bottomContainer,
27+
}: DropdownProps) => {
28+
const handleItemClick = (value: Option['value']) => {
29+
if (onItemSelect) {
30+
onItemSelect(value);
31+
}
32+
};
33+
34+
return (
35+
<Dropdown>
36+
{topContainer}
37+
<ItemsContainer>
38+
{loading ? (
39+
<Container>
40+
<IonSpinner />
41+
</Container>
42+
) : options.length ? (
43+
options.map((option) => (
44+
<IonDropdownItem
45+
{...option}
46+
key={option.value}
47+
onClick={() => handleItemClick(option.value)}
48+
/>
49+
))
50+
) : (
51+
<Container>
52+
<IonEmpty label='Não há dados' />
53+
</Container>
54+
)}
55+
</ItemsContainer>
56+
{bottomContainer}
57+
</Dropdown>
58+
);
59+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { renderWithTheme } from '@ion/components/utils/test-utils';
2+
import { screen } from '@testing-library/dom';
3+
import userEvent from '@testing-library/user-event';
4+
import { DropdownItemProps, IonDropdownItem } from './dropdownItem';
5+
6+
const sut = (props: DropdownItemProps) => {
7+
renderWithTheme(<IonDropdownItem {...props} />);
8+
};
9+
10+
describe('DropdownItem', () => {
11+
describe('Default', () => {
12+
it('should render a label', () => {
13+
const label = 'Item';
14+
sut({ label });
15+
expect(screen.getByText(label)).toBeInTheDocument();
16+
});
17+
it('should render an icon', () => {
18+
const icon = 'access';
19+
sut({ label: 'Item', icon });
20+
expect(screen.getByTestId('ion-icon-access')).toBeInTheDocument();
21+
});
22+
it('should emit a click', async () => {
23+
const onClick = jest.fn();
24+
sut({ label: 'Item', onClick });
25+
await userEvent.click(screen.getByText('Item'));
26+
expect(onClick).toHaveBeenCalled();
27+
});
28+
});
29+
describe('Selected', () => {
30+
it('should render a selected item', () => {
31+
sut({ label: 'Item', selected: true });
32+
expect(screen.getByTestId('ion-icon-check')).toBeInTheDocument();
33+
});
34+
it('should show a close icon when hovering', async () => {
35+
sut({ label: 'Item', selected: true });
36+
expect(screen.getByTestId('ion-icon-check')).toBeInTheDocument();
37+
await userEvent.hover(screen.getByText('Item'));
38+
expect(screen.getByTestId('ion-icon-close')).toBeInTheDocument();
39+
});
40+
it('should reset to check icon when unhovering', async () => {
41+
sut({ label: 'Item', selected: true });
42+
expect(screen.getByTestId('ion-icon-check')).toBeInTheDocument();
43+
await userEvent.hover(screen.getByText('Item'));
44+
expect(screen.getByTestId('ion-icon-close')).toBeInTheDocument();
45+
await userEvent.unhover(screen.getByText('Item'));
46+
expect(screen.getByTestId('ion-icon-check')).toBeInTheDocument();
47+
});
48+
});
49+
describe('Disabled', () => {
50+
it('should not emit a click', async () => {
51+
const onClick = jest.fn();
52+
sut({ label: 'Item', disabled: true, onClick });
53+
await userEvent.click(screen.getByText('Item'));
54+
expect(onClick).not.toHaveBeenCalled();
55+
});
56+
it('should render check icon when selected', () => {
57+
sut({ label: 'Item', selected: true, disabled: true });
58+
expect(screen.getByTestId('ion-icon-check')).toBeInTheDocument();
59+
});
60+
it('should not render close icon when hovering', async () => {
61+
sut({ label: 'Item', selected: true, disabled: true });
62+
await userEvent.hover(screen.getByText('Item'));
63+
expect(screen.queryByTestId('ion-icon-close')).not.toBeInTheDocument();
64+
});
65+
});
66+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState } from 'react';
2+
import { IonIcon, IonIconProps } from '../../icons';
3+
import { Container, Label } from './styles';
4+
5+
export interface DropdownItemProps {
6+
label: string;
7+
icon?: IonIconProps['type'];
8+
selected?: boolean;
9+
disabled?: boolean;
10+
onClick?: () => void;
11+
}
12+
13+
export const IonDropdownItem = ({
14+
label,
15+
selected,
16+
disabled,
17+
icon,
18+
onClick,
19+
}: DropdownItemProps) => {
20+
const [hover, setHover] = useState(false);
21+
22+
const handleHover = () => {
23+
if (!disabled) {
24+
setHover(true);
25+
}
26+
};
27+
28+
const handleUnhover = () => {
29+
setHover(false);
30+
};
31+
32+
const handleClick = () => {
33+
if (!disabled && onClick) {
34+
onClick();
35+
}
36+
};
37+
38+
return (
39+
<Container
40+
$selected={selected}
41+
$disabled={disabled}
42+
onMouseEnter={handleHover}
43+
onMouseLeave={handleUnhover}
44+
onClick={handleClick}
45+
>
46+
<Label>
47+
{icon && <IonIcon type={icon} size={16} />}
48+
<span>{label}</span>
49+
</Label>
50+
{selected && <IonIcon type={hover ? 'close' : 'check'} size={16} />}
51+
</Container>
52+
);
53+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { css, styled } from 'styled-components';
2+
3+
interface DropdownItemStylesProps {
4+
$selected?: boolean;
5+
$disabled?: boolean;
6+
}
7+
8+
export const Label = styled.div`
9+
${({ theme }) => css`
10+
${theme.utils.flex.start(8)}
11+
`}
12+
`;
13+
14+
export const Container = styled.div<DropdownItemStylesProps>`
15+
${({ theme, $selected, $disabled }) => css`
16+
${theme.utils.flex.spaceBetween(8)}
17+
${theme.font.size[14]}
18+
font-weight: 400;
19+
height: 32px;
20+
border-radius: 8px;
21+
padding: 6px 12px;
22+
background: ${theme.colors.neutral[1]};
23+
color: ${theme.colors.neutral[7]};
24+
cursor: pointer;
25+
26+
svg {
27+
fill: ${theme.colors.neutral[7]};
28+
}
29+
30+
${!$disabled &&
31+
css`
32+
&:hover {
33+
background: ${theme.colors.primary[1]};
34+
}
35+
36+
&:active {
37+
background: ${theme.colors.primary[2]};
38+
color: ${theme.colors.primary[5]};
39+
40+
svg {
41+
fill: ${theme.colors.primary[5]};
42+
}
43+
}
44+
45+
${$selected &&
46+
css`
47+
background: ${theme.colors.primary[1]};
48+
color: ${theme.colors.primary[5]};
49+
50+
svg {
51+
fill: ${theme.colors.primary[5]};
52+
}
53+
54+
&:hover {
55+
color: ${theme.colors.primary[4]};
56+
57+
svg {
58+
fill: ${theme.colors.primary[4]};
59+
}
60+
}
61+
62+
&:active {
63+
background: ${theme.colors.primary[2]};
64+
color: ${theme.colors.primary[7]};
65+
66+
svg {
67+
fill: ${theme.colors.primary[7]};
68+
}
69+
}
70+
`}
71+
`}
72+
73+
${$disabled &&
74+
css`
75+
cursor: not-allowed;
76+
background: ${theme.colors.neutral[1]};
77+
color: ${theme.colors.neutral[3]};
78+
79+
svg {
80+
fill: ${theme.colors.neutral[3]};
81+
}
82+
83+
${$selected &&
84+
css`
85+
background: ${theme.colors.neutral[2]};
86+
color: ${theme.colors.neutral[5]};
87+
88+
svg {
89+
fill: ${theme.colors.neutral[5]};
90+
}
91+
`}
92+
`}
93+
`}
94+
`;

src/components/dropdown/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dropdown';

src/components/dropdown/styles.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { css, styled } from 'styled-components';
2+
3+
export const columnWrapper = css`
4+
display: flex;
5+
flex-direction: column;
6+
gap: 4px;
7+
`;
8+
9+
export const Dropdown = styled.div`
10+
${({ theme }) => css`
11+
width: 182px;
12+
max-height: 316px;
13+
padding: 12px 8px;
14+
border-radius: 8px;
15+
background: ${theme.colors.neutral[1]};
16+
${theme.font.size[14]}
17+
font-weight: 600;
18+
color: ${theme.colors.neutral[7]};
19+
${columnWrapper}
20+
${theme.utils.shadow.doubleShadow};
21+
22+
::-webkit-scrollbar {
23+
width: 8px;
24+
}
25+
26+
::-webkit-scrollbar-track {
27+
background: ${theme.colors.neutral[1]};
28+
}
29+
30+
::-webkit-scrollbar-thumb,
31+
::-webkit-scrollbar-thumb:active,
32+
::-webkit-scrollbar-thumb:hover {
33+
border-radius: 10px;
34+
background: ${theme.colors.neutral[6]};
35+
}
36+
`}
37+
`;
38+
39+
export const ItemsContainer = styled.div`
40+
max-height: 212px;
41+
overflow-y: auto;
42+
${columnWrapper}
43+
`;
44+
45+
export const Container = styled.div`
46+
${({ theme }) => css`
47+
${theme.utils.flex.center()}
48+
padding: 16px 0;
49+
`}
50+
`;

0 commit comments

Comments
 (0)