From 1d8eb8fae27978b534072189aff8cd58e91c88c4 Mon Sep 17 00:00:00 2001 From: Tristan Hitt <39978624+thitt7@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:57:40 -0500 Subject: [PATCH] feat(components): Add SelectScrollButton for scrolling dropdown, visual overflow indicator --- .../Select/SelectScrollButton/index.tsx | 130 ++++++++++++++++++ .../Common/Select/index.module.css | 29 ++++ .../ui-components/Common/Select/index.tsx | 9 +- 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/ui-components/Common/Select/SelectScrollButton/index.tsx diff --git a/packages/ui-components/Common/Select/SelectScrollButton/index.tsx b/packages/ui-components/Common/Select/SelectScrollButton/index.tsx new file mode 100644 index 0000000000000..bd03c7435c40d --- /dev/null +++ b/packages/ui-components/Common/Select/SelectScrollButton/index.tsx @@ -0,0 +1,130 @@ +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; +import { useEffect, useRef, useState } from 'react'; +import { type FC, type RefObject } from 'react'; + +import styles from '@node-core/ui-components/Common/Select/index.module.css'; + +type SelectScrollButtonProps = { + direction: 'up' | 'down'; + selectContentRef?: RefObject; + scrollAmount?: number; + scrollInterval?: number; +}; + +const SelectScrollButton: FC = ({ + direction, + selectContentRef, + scrollAmount = 35, + scrollInterval = 50, +}) => { + const DirectionComponent = + direction === 'down' ? ChevronDownIcon : ChevronUpIcon; + const [isVisible, setIsVisible] = useState(false); + const [hasOverflow, setOverflow] = useState(false); + const intervalRef = useRef(null); + const isScrollingRef = useRef(false); + + const clearScrollInterval = () => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + const startScrolling = () => { + if (!selectContentRef?.current || !isVisible || !hasOverflow) return; + + clearScrollInterval(); + + intervalRef.current = window.setInterval(() => { + if (!selectContentRef.current || !isScrollingRef.current) return; + + const container = selectContentRef.current; + + if (direction === 'down') { + container.scrollBy({ top: scrollAmount, behavior: 'smooth' }); + + if ( + container.scrollTop >= + container.scrollHeight - container.clientHeight + ) { + clearScrollInterval(); + setIsVisible(false); + } + } else { + container.scrollBy({ + top: -Math.abs(scrollAmount), + behavior: 'smooth', + }); + + if (container.scrollTop <= 0) { + clearScrollInterval(); + setIsVisible(false); + } + } + }, scrollInterval); + }; + + useEffect(() => { + if (!selectContentRef?.current) return; + + const container = selectContentRef.current; + setOverflow(container.scrollHeight > container.clientHeight); + + const updateButtonVisibility = () => { + if (!container) return; + + if (direction === 'down') { + setIsVisible( + container.scrollTop < container.scrollHeight - container.clientHeight + ); + } else { + setIsVisible(container.scrollTop > 0); + } + }; + + updateButtonVisibility(); + + const handleScroll = () => { + updateButtonVisibility(); + + if (!isScrollingRef.current && intervalRef.current !== null) { + clearScrollInterval(); + } + }; + + container.addEventListener('scroll', handleScroll); + window.addEventListener('resize', updateButtonVisibility); + + return () => { + container.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', updateButtonVisibility); + clearScrollInterval(); + }; + }, [direction, selectContentRef]); + + const handleMouseEnter = () => { + isScrollingRef.current = true; + startScrolling(); + }; + + const handleMouseLeave = () => { + isScrollingRef.current = false; + clearScrollInterval(); + }; + + if (!isVisible) return null; + + return ( +
+
+ ); +}; + +export default SelectScrollButton; diff --git a/packages/ui-components/Common/Select/index.module.css b/packages/ui-components/Common/Select/index.module.css index 1e13248039a83..03df3d20f59fd 100644 --- a/packages/ui-components/Common/Select/index.module.css +++ b/packages/ui-components/Common/Select/index.module.css @@ -149,3 +149,32 @@ rounded; } } + +.scrollBtn { + @apply sticky + z-10 + flex + w-full + cursor-pointer + justify-center + bg-white + p-1 + transition-colors + hover:bg-neutral-100 + dark:bg-neutral-950 + dark:hover:bg-neutral-900; +} + +.scrollBtn[data-direction='down'] { + bottom: 0; +} + +.scrollBtn[data-direction='up'] { + top: 0; +} + +.scrollBtnIcon { + @apply size-5 + text-neutral-600 + dark:text-neutral-400; +} diff --git a/packages/ui-components/Common/Select/index.tsx b/packages/ui-components/Common/Select/index.tsx index 88df36658e897..08ce59ffdf8c8 100644 --- a/packages/ui-components/Common/Select/index.tsx +++ b/packages/ui-components/Common/Select/index.tsx @@ -4,9 +4,10 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; import * as ScrollPrimitive from '@radix-ui/react-scroll-area'; import * as SelectPrimitive from '@radix-ui/react-select'; import classNames from 'classnames'; -import { useEffect, useId, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState, useRef } from 'react'; import type { ReactElement, ReactNode } from 'react'; +import SelectScrollButton from '@node-core/ui-components/Common/Select/SelectScrollButton'; import Skeleton from '@node-core/ui-components/Common/Skeleton'; import type { FormattedMessage } from '@node-core/ui-components/types'; @@ -59,6 +60,7 @@ const Select = ({ }: SelectProps): ReactNode => { const id = useId(); const [value, setValue] = useState(defaultValue); + const SelectContentRef = useRef(null); useEffect(() => setValue(defaultValue), [defaultValue]); @@ -163,6 +165,7 @@ const Select = ({ ({ +