Skip to content

Commit 1d8eb8f

Browse files
committed
feat(components): Add SelectScrollButton for scrolling dropdown, visual overflow indicator
1 parent 9006f87 commit 1d8eb8f

3 files changed

Lines changed: 167 additions & 1 deletion

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
2+
import { useEffect, useRef, useState } from 'react';
3+
import { type FC, type RefObject } from 'react';
4+
5+
import styles from '@node-core/ui-components/Common/Select/index.module.css';
6+
7+
type SelectScrollButtonProps = {
8+
direction: 'up' | 'down';
9+
selectContentRef?: RefObject<HTMLDivElement | null>;
10+
scrollAmount?: number;
11+
scrollInterval?: number;
12+
};
13+
14+
const SelectScrollButton: FC<SelectScrollButtonProps> = ({
15+
direction,
16+
selectContentRef,
17+
scrollAmount = 35,
18+
scrollInterval = 50,
19+
}) => {
20+
const DirectionComponent =
21+
direction === 'down' ? ChevronDownIcon : ChevronUpIcon;
22+
const [isVisible, setIsVisible] = useState(false);
23+
const [hasOverflow, setOverflow] = useState(false);
24+
const intervalRef = useRef<number | null>(null);
25+
const isScrollingRef = useRef(false);
26+
27+
const clearScrollInterval = () => {
28+
if (intervalRef.current !== null) {
29+
window.clearInterval(intervalRef.current);
30+
intervalRef.current = null;
31+
}
32+
};
33+
34+
const startScrolling = () => {
35+
if (!selectContentRef?.current || !isVisible || !hasOverflow) return;
36+
37+
clearScrollInterval();
38+
39+
intervalRef.current = window.setInterval(() => {
40+
if (!selectContentRef.current || !isScrollingRef.current) return;
41+
42+
const container = selectContentRef.current;
43+
44+
if (direction === 'down') {
45+
container.scrollBy({ top: scrollAmount, behavior: 'smooth' });
46+
47+
if (
48+
container.scrollTop >=
49+
container.scrollHeight - container.clientHeight
50+
) {
51+
clearScrollInterval();
52+
setIsVisible(false);
53+
}
54+
} else {
55+
container.scrollBy({
56+
top: -Math.abs(scrollAmount),
57+
behavior: 'smooth',
58+
});
59+
60+
if (container.scrollTop <= 0) {
61+
clearScrollInterval();
62+
setIsVisible(false);
63+
}
64+
}
65+
}, scrollInterval);
66+
};
67+
68+
useEffect(() => {
69+
if (!selectContentRef?.current) return;
70+
71+
const container = selectContentRef.current;
72+
setOverflow(container.scrollHeight > container.clientHeight);
73+
74+
const updateButtonVisibility = () => {
75+
if (!container) return;
76+
77+
if (direction === 'down') {
78+
setIsVisible(
79+
container.scrollTop < container.scrollHeight - container.clientHeight
80+
);
81+
} else {
82+
setIsVisible(container.scrollTop > 0);
83+
}
84+
};
85+
86+
updateButtonVisibility();
87+
88+
const handleScroll = () => {
89+
updateButtonVisibility();
90+
91+
if (!isScrollingRef.current && intervalRef.current !== null) {
92+
clearScrollInterval();
93+
}
94+
};
95+
96+
container.addEventListener('scroll', handleScroll);
97+
window.addEventListener('resize', updateButtonVisibility);
98+
99+
return () => {
100+
container.removeEventListener('scroll', handleScroll);
101+
window.removeEventListener('resize', updateButtonVisibility);
102+
clearScrollInterval();
103+
};
104+
}, [direction, selectContentRef]);
105+
106+
const handleMouseEnter = () => {
107+
isScrollingRef.current = true;
108+
startScrolling();
109+
};
110+
111+
const handleMouseLeave = () => {
112+
isScrollingRef.current = false;
113+
clearScrollInterval();
114+
};
115+
116+
if (!isVisible) return null;
117+
118+
return (
119+
<div
120+
className={styles.scrollBtn}
121+
data-direction={direction}
122+
onMouseEnter={handleMouseEnter}
123+
onMouseLeave={handleMouseLeave}
124+
>
125+
<DirectionComponent className={styles.scrollBtnIcon} aria-hidden="true" />
126+
</div>
127+
);
128+
};
129+
130+
export default SelectScrollButton;

packages/ui-components/Common/Select/index.module.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,32 @@
149149
rounded;
150150
}
151151
}
152+
153+
.scrollBtn {
154+
@apply sticky
155+
z-10
156+
flex
157+
w-full
158+
cursor-pointer
159+
justify-center
160+
bg-white
161+
p-1
162+
transition-colors
163+
hover:bg-neutral-100
164+
dark:bg-neutral-950
165+
dark:hover:bg-neutral-900;
166+
}
167+
168+
.scrollBtn[data-direction='down'] {
169+
bottom: 0;
170+
}
171+
172+
.scrollBtn[data-direction='up'] {
173+
top: 0;
174+
}
175+
176+
.scrollBtnIcon {
177+
@apply size-5
178+
text-neutral-600
179+
dark:text-neutral-400;
180+
}

packages/ui-components/Common/Select/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
44
import * as ScrollPrimitive from '@radix-ui/react-scroll-area';
55
import * as SelectPrimitive from '@radix-ui/react-select';
66
import classNames from 'classnames';
7-
import { useEffect, useId, useMemo, useState } from 'react';
7+
import { useEffect, useId, useMemo, useState, useRef } from 'react';
88
import type { ReactElement, ReactNode } from 'react';
99

10+
import SelectScrollButton from '@node-core/ui-components/Common/Select/SelectScrollButton';
1011
import Skeleton from '@node-core/ui-components/Common/Skeleton';
1112
import type { FormattedMessage } from '@node-core/ui-components/types';
1213

@@ -59,6 +60,7 @@ const Select = <T extends string>({
5960
}: SelectProps<T>): ReactNode => {
6061
const id = useId();
6162
const [value, setValue] = useState(defaultValue);
63+
const SelectContentRef = useRef<HTMLDivElement>(null);
6264

6365
useEffect(() => setValue(defaultValue), [defaultValue]);
6466

@@ -163,6 +165,7 @@ const Select = <T extends string>({
163165

164166
<SelectPrimitive.Portal>
165167
<SelectPrimitive.Content
168+
ref={SelectContentRef}
166169
position={inline ? 'popper' : 'item-aligned'}
167170
className={classNames(styles.dropdown, {
168171
[styles.inline]: inline,
@@ -178,6 +181,10 @@ const Select = <T extends string>({
178181
<ScrollPrimitive.Thumb />
179182
</ScrollPrimitive.Scrollbar>
180183
</ScrollPrimitive.Root>
184+
<SelectScrollButton
185+
direction="down"
186+
selectContentRef={SelectContentRef}
187+
/>
181188
</SelectPrimitive.Content>
182189
</SelectPrimitive.Portal>
183190
</SelectPrimitive.Root>

0 commit comments

Comments
 (0)