Skip to content

Commit 8b088b4

Browse files
authored
Merge pull request #439 from dump-hr/lovretomic/dropdown
Dropdown Component
2 parents 4ace9c8 + 7abb0f2 commit 8b088b4

File tree

10 files changed

+305
-48
lines changed

10 files changed

+305
-48
lines changed

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"preview": "vite preview"
1111
},
1212
"dependencies": {
13+
"clsx": "^2.1.1",
1314
"react": "^18.3.1",
1415
"react-dom": "^18.3.1"
1516
},

apps/app/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
function App() {
22
return (
33
<>
4-
<p>DUMP Days 2025 App</p>
4+
<h1>App</h1>
55
</>
66
);
77
}
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
.wrapper {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: flex-start;
5+
gap: 4px;
6+
position: relative;
7+
8+
.label {
9+
@include paragraph-14;
10+
color: $black-50;
11+
}
12+
13+
.mainButton {
14+
@include paragraph-16;
15+
background-color: $black-10;
16+
outline: none;
17+
border: none;
18+
cursor: pointer;
19+
width: 100%;
20+
21+
box-sizing: border-box;
22+
padding: 16px;
23+
border-radius: 4px;
24+
25+
display: flex;
26+
justify-content: space-between;
27+
align-items: center;
28+
gap: 8px;
29+
30+
transition: box-shadow 200ms;
31+
32+
.arrow {
33+
transition: 200ms;
34+
}
35+
36+
&.isOpen {
37+
box-shadow: inset 0 0 0 1px $black-30;
38+
39+
.arrow {
40+
rotate: 180deg;
41+
}
42+
}
43+
44+
&.isError {
45+
box-shadow: inset 0 0 0 1px $error-light;
46+
}
47+
}
48+
49+
.errorLabel {
50+
@include paragraph-14;
51+
color: $error-light;
52+
}
53+
54+
.optionsWrapper {
55+
box-sizing: border-box;
56+
padding: 16px;
57+
box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.12);
58+
background-color: white;
59+
border-radius: 4px;
60+
position: absolute;
61+
top: 86px;
62+
width: 100%;
63+
z-index: 100;
64+
65+
.innerContainer {
66+
display: block;
67+
width: 100%;
68+
box-sizing: border-box;
69+
70+
max-height: 310px;
71+
overflow-y: auto;
72+
73+
&::-webkit-scrollbar {
74+
width: 4px;
75+
border-radius: 100px;
76+
}
77+
78+
&::-webkit-scrollbar-track {
79+
background: $black-10;
80+
border-radius: 100px;
81+
}
82+
83+
&::-webkit-scrollbar-thumb {
84+
background: $primary-black;
85+
border-radius: 100px;
86+
}
87+
88+
&::-webkit-scrollbar-thumb:hover {
89+
background: $primary-black;
90+
}
91+
92+
.divider {
93+
@include dottedBreak(rgba(23, 22, 21, 0.65));
94+
height: 4px;
95+
margin-bottom: 16px;
96+
margin-top: 8px;
97+
}
98+
99+
.option {
100+
background: none;
101+
outline: none;
102+
border: none;
103+
@include paragraph-16;
104+
margin: 0;
105+
padding: 0;
106+
text-align: left;
107+
cursor: pointer;
108+
width: 100%;
109+
110+
&.selected {
111+
color: $black-30;
112+
cursor: default;
113+
}
114+
}
115+
}
116+
}
117+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useRef, useState } from 'react';
2+
import React from 'react';
3+
import c from './Dropdown.module.scss';
4+
import { DropdownOption } from './DropdownOption';
5+
import ArrowIcon from '../../assets/icons/arrow-down-1.svg';
6+
import clsx from 'clsx';
7+
import { useClickOutside } from '../../hooks/useClickOutside';
8+
9+
type DropdownProps = {
10+
label: string;
11+
placeholder: string;
12+
options: DropdownOption[];
13+
setOption: (option: DropdownOption) => void;
14+
selectedOption: DropdownOption | undefined;
15+
errorLabel?: string;
16+
showError?: boolean;
17+
width?: string;
18+
hasError?: boolean;
19+
};
20+
21+
const Dropdown = ({
22+
label,
23+
placeholder,
24+
options,
25+
setOption,
26+
selectedOption,
27+
errorLabel,
28+
width = 'auto',
29+
hasError = false,
30+
}: DropdownProps) => {
31+
const [isOpen, setIsOpen] = useState(false);
32+
const dropdownRef = useRef(null);
33+
34+
const toggle = () => {
35+
setIsOpen(!isOpen);
36+
};
37+
38+
function handleOptionSelected(option: DropdownOption) {
39+
setOption(option);
40+
setIsOpen(false);
41+
}
42+
43+
useClickOutside(dropdownRef, () => setIsOpen(false));
44+
45+
const widthStyle = { width: width };
46+
const showError = (hasError || !selectedOption?.value) && !isOpen;
47+
48+
return (
49+
<div className={c.wrapper} style={widthStyle} ref={dropdownRef}>
50+
{label && <label className={c.label}>{label}</label>}
51+
52+
<button
53+
className={clsx({
54+
[c.mainButton]: true,
55+
[c.isOpen]: isOpen,
56+
[c.isError]: showError,
57+
})}
58+
onClick={toggle}>
59+
{selectedOption?.label || placeholder}
60+
<img className={c.arrow} src={ArrowIcon} alt='arrow' />
61+
</button>
62+
63+
{showError && <div className={c.errorLabel}>{errorLabel}</div>}
64+
65+
{isOpen && (
66+
<div className={c.optionsWrapper}>
67+
<div className={c.innerContainer}>
68+
{options.map((option, i) => (
69+
<React.Fragment key={option.value}>
70+
{i !== 0 && <div className={c.divider} key={i}></div>}
71+
<button
72+
disabled={option.value === selectedOption?.value}
73+
className={clsx({
74+
[c.option]: true,
75+
[c.selected]: option.value === selectedOption?.value,
76+
})}
77+
key={option.value}
78+
onClick={() => handleOptionSelected(option)}>
79+
{option.label}
80+
</button>
81+
</React.Fragment>
82+
))}
83+
</div>
84+
</div>
85+
)}
86+
</div>
87+
);
88+
};
89+
90+
export default Dropdown;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type DropdownOption = {
2+
value: string;
3+
label: string;
4+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Dropdown from './Dropdown';
2+
3+
export default Dropdown;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { RefObject, useEffect } from 'react';
2+
3+
export const useClickOutside = (
4+
ref: RefObject<HTMLElement>,
5+
handleOnClickOutside: (event: MouseEvent | TouchEvent) => void,
6+
) => {
7+
useEffect(() => {
8+
const listener = (event: MouseEvent | TouchEvent) => {
9+
if (!ref.current || ref.current.contains(event.target as Node)) {
10+
return;
11+
}
12+
handleOnClickOutside(event);
13+
};
14+
document.addEventListener('mousedown', listener);
15+
document.addEventListener('touchstart', listener);
16+
return () => {
17+
document.removeEventListener('mousedown', listener);
18+
document.removeEventListener('touchstart', listener);
19+
};
20+
}, [ref, handleOnClickOutside]);
21+
};

apps/app/src/styles/_mixins.scss

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,89 @@
1-
@import "./fonts";
1+
@import './fonts';
22

33
@mixin heading-1 {
4-
font-family: NeueMachina;
5-
font-size: 2rem;
6-
line-height: 2.25rem;
7-
letter-spacing: -1%;
4+
font-family: NeueMachina;
5+
font-size: 2rem;
6+
line-height: 2.25rem;
7+
letter-spacing: -1%;
88
}
99

1010
@mixin heading-2 {
11-
font-family: NeueMachina;
12-
font-size: 1.5rem;
13-
line-height: 1.75rem;
14-
letter-spacing: -1%;
11+
font-family: NeueMachina;
12+
font-size: 1.5rem;
13+
line-height: 1.75rem;
14+
letter-spacing: -1%;
1515
}
1616

1717
@mixin heading-3 {
18-
font-family: NeueMachina;
19-
font-size: 1rem;
20-
line-height: 1.25rem;
21-
letter-spacing: -1%;
18+
font-family: NeueMachina;
19+
font-size: 1rem;
20+
line-height: 1.25rem;
21+
letter-spacing: -1%;
2222
}
2323

2424
@mixin label-large {
25-
font-family: Inter;
26-
font-weight: 700;
27-
font-size: 1.25rem;
28-
line-height: 1.75rem;
29-
letter-spacing: 0%;
25+
font-family: Inter;
26+
font-weight: 700;
27+
font-size: 1.25rem;
28+
line-height: 1.75rem;
29+
letter-spacing: 0%;
3030
}
3131

3232
@mixin label-medium {
33-
font-family: Inter;
34-
font-weight: 600;
35-
font-size: 1rem;
36-
line-height: 1.375rem;
37-
letter-spacing: 0%;
33+
font-family: Inter;
34+
font-weight: 600;
35+
font-size: 1rem;
36+
line-height: 1.375rem;
37+
letter-spacing: 0%;
3838
}
3939

4040
@mixin label-small {
41-
font-family: Inter;
42-
font-weight: 600;
43-
font-size: 0.875rem;
44-
line-height: 1.125rem;
45-
letter-spacing: 2%;
41+
font-family: Inter;
42+
font-weight: 600;
43+
font-size: 0.875rem;
44+
line-height: 1.125rem;
45+
letter-spacing: 2%;
4646
}
4747

4848
@mixin paragraph-16 {
49-
font-family: Inter;
50-
font-weight: 400;
51-
font-size: 1rem;
52-
line-height: 1.375rem;
53-
letter-spacing: -1%;
49+
font-family: Inter;
50+
font-weight: 400;
51+
font-size: 1rem;
52+
line-height: 1.375rem;
53+
letter-spacing: -1%;
5454
}
5555

5656
@mixin paragraph-14 {
57-
font-family: Inter;
58-
font-weight: 400;
59-
font-size: 0.875rem;
60-
line-height: 1.25rem;
61-
letter-spacing: -1%;
57+
font-family: Inter;
58+
font-weight: 400;
59+
font-size: 0.875rem;
60+
line-height: 1.25rem;
61+
letter-spacing: -1%;
6262
}
6363

6464
@mixin tag-16 {
65-
font-family: NeueMontrealMono;
66-
font-size: 1rem;
67-
line-height: 1.125rem;
68-
letter-spacing: 0%;
65+
font-family: NeueMontrealMono;
66+
font-size: 1rem;
67+
line-height: 1.125rem;
68+
letter-spacing: 0%;
6969
}
7070

7171
@mixin tag-14 {
72-
font-family: NeueMontrealMono;
73-
font-size: 0.875rem;
74-
line-height: 1rem;
75-
letter-spacing: 0%;
76-
}
72+
font-family: NeueMontrealMono;
73+
font-size: 0.875rem;
74+
line-height: 1rem;
75+
letter-spacing: 0%;
76+
}
77+
78+
@mixin dottedBreak($color: rgba(23, 22, 21, 0.3), $dot-size: 0.8px) {
79+
width: 100%;
80+
height: 2px;
81+
background-image: radial-gradient(
82+
circle,
83+
$color $dot-size,
84+
transparent calc($dot-size + 0.3px)
85+
);
86+
background-size: calc($dot-size * 10) 2px;
87+
background-repeat: repeat-x;
88+
background-color: transparent;
89+
}

0 commit comments

Comments
 (0)