Files
ncd-fe/packages/pl-fe/src/components/scroll-top-button.tsx
nicole mikołajczyk 6ceee73b60 nicolium: migrate notifications to tanstack/react-query
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-23 22:51:10 +01:00

114 lines
3.5 KiB
TypeScript

import clsx from 'clsx';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useCallback } from 'react';
import { useIntl, MessageDescriptor } from 'react-intl';
import Icon from '@/components/ui/icon';
import { useSettings } from '@/stores/settings';
interface IScrollTopButton {
/** Callback when clicked, and also when scrolled to the top. */
onClick: () => void;
/** Number of unread items. */
count: number;
/** Message to display in the button (should contain a `{count}` value). */
message: MessageDescriptor;
/** Message to announce in the live region (should contain a `{count}` value). If not provided, `message` will be used. */
liveRegionMessage?: MessageDescriptor;
/** Distance from the top of the screen (scrolling down) before the button appears. */
threshold?: number;
/** Distance from the top of the screen (scrolling up) before the action is triggered. */
autoloadThreshold?: number;
}
/** Floating new post counter above timelines, clicked to scroll to top. */
const ScrollTopButton: React.FC<IScrollTopButton> = ({
onClick,
count,
message,
threshold = 240,
autoloadThreshold = 50,
liveRegionMessage = message,
}) => {
const intl = useIntl();
const { autoloadTimelines } = useSettings();
// Whether we are scrolled past the `threshold`.
const [scrolled, setScrolled] = useState<boolean>(false);
// Whether we are scrolled above the `autoloadThreshold`.
const [scrolledTop, setScrolledTop] = useState<boolean>(false);
const visible = count > 0 && (!autoloadTimelines || scrolled);
const buttonMessage = intl.formatMessage(message, { count });
/** Number of pixels scrolled down from the top of the page. */
const getScrollTop = (): number =>
(document.scrollingElement ?? document.documentElement).scrollTop;
/** Unload feed items if scrolled to the top. */
const maybeUnload = useCallback(() => {
if (autoloadTimelines && scrolledTop && count) {
onClick();
}
}, [autoloadTimelines, scrolledTop, count, onClick]);
/** Set state while scrolling. */
const handleScroll = useCallback(
throttle(
() => {
const scrollTop = getScrollTop();
setScrolled(scrollTop > threshold);
setScrolledTop(scrollTop <= autoloadThreshold);
},
40,
{ trailing: true },
),
[threshold, autoloadThreshold],
);
/** Scroll to top and trigger `onClick`. */
const handleClick: React.MouseEventHandler = useCallback(() => {
window.scrollTo({ top: 0 });
onClick();
}, [onClick]);
useEffect(() => {
// Delay adding the scroll listener so navigating back doesn't
// unload feed items before the feed is rendered.
setTimeout(() => {
window.addEventListener('scroll', handleScroll);
handleScroll();
}, 250);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
useEffect(() => {
maybeUnload();
}, [maybeUnload]);
return (
<>
<span className='sr-only' role='status' aria-live='polite' aria-atomic='true'>
{visible ? intl.formatMessage(liveRegionMessage, { count }) : ''}
</span>
<div
className={clsx('⁂-scroll-top-button', { '⁂-scroll-top-button--visible': visible })}
aria-hidden={!visible}
>
<button onClick={handleClick} tabIndex={visible ? 0 : -1} aria-label={buttonMessage}>
<Icon src={require('@phosphor-icons/core/regular/arrow-line-up.svg')} aria-hidden />
<p>{buttonMessage}</p>
</button>
</div>
</>
);
};
export { ScrollTopButton as default };