diff --git a/packages/shared/src/components/MenuIcon.tsx b/packages/shared/src/components/MenuIcon.tsx index e69fb86fe4b..6d5d02ed9e2 100644 --- a/packages/shared/src/components/MenuIcon.tsx +++ b/packages/shared/src/components/MenuIcon.tsx @@ -14,9 +14,9 @@ export const MenuIcon = ({ }: MenuIconProps): ReactElement => { return ( ); }; diff --git a/packages/shared/src/components/dropdown/DropdownMenu.tsx b/packages/shared/src/components/dropdown/DropdownMenu.tsx index bf49b1c95e2..5ba594b0797 100644 --- a/packages/shared/src/components/dropdown/DropdownMenu.tsx +++ b/packages/shared/src/components/dropdown/DropdownMenu.tsx @@ -23,6 +23,7 @@ import Link from '../utilities/Link'; import type { MenuItemProps } from './common'; import { useRequestProtocol } from '../../hooks/useRequestProtocol'; import { getCompanionWrapper } from '../../lib/extension'; +import { useScrollFade } from '../../hooks/useScrollFade'; export const DropdownMenuItem = classed( DropdownMenuItemRoot, @@ -34,6 +35,7 @@ interface DropdownMenuContentProps children: ReactNode; className?: string; align?: 'start' | 'center' | 'end'; + variant?: 'action' | 'field'; } export const DropdownMenuTrigger = React.forwardRef< @@ -100,24 +102,49 @@ DropdownMenu.displayName = 'DropdownMenu'; export const DropdownMenuContent = React.forwardRef< HTMLDivElement, DropdownMenuContentProps ->(({ children, className, align = 'end', ...props }, forwardedRef) => { - const { isCompanion } = useRequestProtocol(); - const container = isCompanion ? getCompanionWrapper() : undefined; - return ( - - -
- {children} -
-
-
- ); -}); +>( + ( + { + children, + className, + align = 'end', + collisionPadding, + sideOffset, + variant = 'action', + ...props + }, + forwardedRef, + ) => { + const { isCompanion } = useRequestProtocol(); + const container = isCompanion ? getCompanionWrapper() : undefined; + const scrollFadeRef = useScrollFade(); + return ( + + +
+ {children} +
+
+
+ ); + }, +); DropdownMenuContent.displayName = 'DropdownMenuContent'; diff --git a/packages/shared/src/components/dropdown/style.css b/packages/shared/src/components/dropdown/style.css index b5a30a93fc5..7ae8b4f14f4 100644 --- a/packages/shared/src/components/dropdown/style.css +++ b/packages/shared/src/components/dropdown/style.css @@ -31,13 +31,46 @@ animation-name: slideRightAndFade; } +.DropdownMenuContentAction { + @apply min-w-64 max-w-[--radix-dropdown-menu-content-available-width]; +} + +.DropdownMenuContentField { + @apply min-w-40 w-[--radix-dropdown-menu-trigger-width] max-w-[--radix-dropdown-menu-content-available-width]; +} + +.DropdownMenuContentField .DropdownMenuScrollable { + max-height: min(var(--radix-dropdown-menu-content-available-height), 20rem); +} + +.DropdownMenuScrollable { + --scroll-fade-top: 0px; + --scroll-fade-bottom: 0px; + --scroll-fade-top-opacity: 1; + --scroll-fade-bottom-opacity: 1; + -webkit-mask-image: linear-gradient( + to bottom, + rgb(0 0 0 / var(--scroll-fade-top-opacity)) 0, + black var(--scroll-fade-top), + black calc(100% - var(--scroll-fade-bottom)), + rgb(0 0 0 / var(--scroll-fade-bottom-opacity)) 100% + ); + mask-image: linear-gradient( + to bottom, + rgb(0 0 0 / var(--scroll-fade-top-opacity)) 0, + black var(--scroll-fade-top), + black calc(100% - var(--scroll-fade-bottom)), + rgb(0 0 0 / var(--scroll-fade-bottom-opacity)) 100% + ); +} + .DropdownMenuItem, .DropdownMenuCheckboxItem, .DropdownMenuRadioItem, .DropdownMenuSubTrigger { border-radius: 0.625rem; @apply text-text-tertiary; - @apply flex items-center typo-footnote h-8 px-3 py-0 truncate; + @apply flex items-center typo-footnote h-7 px-2 py-0 truncate; } .DropdownMenuItem[data-disabled], .DropdownMenuCheckboxItem[data-disabled], diff --git a/packages/shared/src/components/fields/Dropdown.tsx b/packages/shared/src/components/fields/Dropdown.tsx index 39e455b507f..9072f2e6577 100644 --- a/packages/shared/src/components/fields/Dropdown.tsx +++ b/packages/shared/src/components/fields/Dropdown.tsx @@ -231,6 +231,7 @@ export function Dropdown({ {renderButton()} diff --git a/packages/shared/src/components/post/poll/PollDurationDropdown.tsx b/packages/shared/src/components/post/poll/PollDurationDropdown.tsx index c6475fa9525..a6be7ebc827 100644 --- a/packages/shared/src/components/post/poll/PollDurationDropdown.tsx +++ b/packages/shared/src/components/post/poll/PollDurationDropdown.tsx @@ -80,9 +80,10 @@ const PollDurationDropdown = () => { diff --git a/packages/shared/src/components/profile/ExperienceLevelDropdown.tsx b/packages/shared/src/components/profile/ExperienceLevelDropdown.tsx index e0fd552265f..3ac9871fa74 100644 --- a/packages/shared/src/components/profile/ExperienceLevelDropdown.tsx +++ b/packages/shared/src/components/profile/ExperienceLevelDropdown.tsx @@ -63,10 +63,7 @@ const ExperienceLevelDropdown = ({ '!shadow-[inset_0.125rem_0_0_var(--status-error)]', selectedIndex > -1 && '!text-text-primary', ), - menu: classNames( - menuClassName, - 'menu-primary max-h-[15.375rem] overflow-y-auto p-1', - ), // fit 6 items + menu: classNames(menuClassName, 'menu-primary p-1'), item: classNames(itemClassName, '*:min-h-10 *:!typo-callout'), container: dropdownClassName, }} diff --git a/packages/shared/src/components/profile/MonthSelect.tsx b/packages/shared/src/components/profile/MonthSelect.tsx index 53879995ec6..12f7fc5b968 100644 --- a/packages/shared/src/components/profile/MonthSelect.tsx +++ b/packages/shared/src/components/profile/MonthSelect.tsx @@ -62,9 +62,10 @@ const MonthSelect = ({ diff --git a/packages/shared/src/components/profile/YearSelect.tsx b/packages/shared/src/components/profile/YearSelect.tsx index ee30e04afa2..24c5ab2acc9 100644 --- a/packages/shared/src/components/profile/YearSelect.tsx +++ b/packages/shared/src/components/profile/YearSelect.tsx @@ -61,9 +61,10 @@ const YearSelect = ({ diff --git a/packages/shared/src/hooks/useScrollFade.ts b/packages/shared/src/hooks/useScrollFade.ts new file mode 100644 index 00000000000..dd5b5b97550 --- /dev/null +++ b/packages/shared/src/hooks/useScrollFade.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef } from 'react'; + +const SCROLL_FADE_SIZE = '2rem'; +const TOP_FADE_VARIABLE = '--scroll-fade-top'; +const BOTTOM_FADE_VARIABLE = '--scroll-fade-bottom'; +const TOP_FADE_OPACITY_VARIABLE = '--scroll-fade-top-opacity'; +const BOTTOM_FADE_OPACITY_VARIABLE = '--scroll-fade-bottom-opacity'; +const EDGE_VISIBLE_OPACITY = '0.24'; +const FULLY_VISIBLE_OPACITY = '1'; + +const updateScrollFade = (element: HTMLElement): void => { + const canScroll = element.scrollHeight > element.clientHeight + 1; + if (!canScroll) { + element.style.setProperty(TOP_FADE_VARIABLE, '0px'); + element.style.setProperty(BOTTOM_FADE_VARIABLE, '0px'); + element.style.setProperty(TOP_FADE_OPACITY_VARIABLE, FULLY_VISIBLE_OPACITY); + element.style.setProperty( + BOTTOM_FADE_OPACITY_VARIABLE, + FULLY_VISIBLE_OPACITY, + ); + return; + } + + const canScrollUp = element.scrollTop > 1; + const canScrollDown = + element.scrollTop + element.clientHeight < element.scrollHeight - 1; + + element.style.setProperty( + TOP_FADE_VARIABLE, + canScrollUp ? SCROLL_FADE_SIZE : '0px', + ); + element.style.setProperty( + TOP_FADE_OPACITY_VARIABLE, + canScrollUp ? EDGE_VISIBLE_OPACITY : FULLY_VISIBLE_OPACITY, + ); + element.style.setProperty( + BOTTOM_FADE_VARIABLE, + canScrollDown ? SCROLL_FADE_SIZE : '0px', + ); + element.style.setProperty( + BOTTOM_FADE_OPACITY_VARIABLE, + canScrollDown ? EDGE_VISIBLE_OPACITY : FULLY_VISIBLE_OPACITY, + ); +}; + +export const useScrollFade = () => { + const cleanupRef = useRef<(() => void) | undefined>(undefined); + const frameRef = useRef(undefined); + + const setElementRef = useCallback((element: El | null) => { + cleanupRef.current?.(); + cleanupRef.current = undefined; + + if (!element) { + return; + } + + const scheduleUpdate = () => { + if (frameRef.current !== undefined) { + cancelAnimationFrame(frameRef.current); + } + frameRef.current = requestAnimationFrame(() => updateScrollFade(element)); + }; + + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(element); + element.addEventListener('scroll', scheduleUpdate, { passive: true }); + + updateScrollFade(element); + + cleanupRef.current = () => { + element.removeEventListener('scroll', scheduleUpdate); + resizeObserver.disconnect(); + if (frameRef.current !== undefined) { + cancelAnimationFrame(frameRef.current); + frameRef.current = undefined; + } + }; + }, []); + + useEffect(() => () => cleanupRef.current?.(), []); + + return setElementRef; +};