177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import { mergeRefs } from '@react-aria/utils';
|
|
import classNames from 'classnames';
|
|
import { maxBy } from 'lodash';
|
|
import type { CSSProperties, ReactNode, Ref } from 'react';
|
|
import React, {
|
|
createContext,
|
|
forwardRef,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
isScrollAtBottom,
|
|
isScrollAtTop,
|
|
isScrollOverflowVertical,
|
|
useScrollObserver,
|
|
} from '../../../hooks/useSizeObserver';
|
|
import { strictAssert } from '../../../util/assert';
|
|
|
|
export type FunScrollerProps = Readonly<{
|
|
sectionGap: number;
|
|
onScrollSectionChange?: (id: string) => void;
|
|
children: ReactNode;
|
|
}>;
|
|
|
|
type ScrollerSectionUnobserve = () => void;
|
|
type ScrollerSectionObserve = (element: Element) => ScrollerSectionUnobserve;
|
|
|
|
const ScrollerSectionObserveContext =
|
|
createContext<ScrollerSectionObserve | null>(null);
|
|
|
|
export const FunScroller = forwardRef(function FunScroller(
|
|
props: FunScrollerProps,
|
|
ref: Ref<HTMLDivElement>
|
|
): JSX.Element {
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
const scrollerInnerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [scrollAtTop, setScrollAtTop] = useState(false);
|
|
const [scrollAtBottom, setScrollAtBottom] = useState(false);
|
|
const [scrollVerticalOverflow, setScrollOverflowVertical] = useState(false);
|
|
|
|
useScrollObserver(scrollerRef, scrollerInnerRef, scroll => {
|
|
setScrollAtTop(isScrollAtTop(scroll));
|
|
setScrollAtBottom(isScrollAtBottom(scroll));
|
|
setScrollOverflowVertical(isScrollOverflowVertical(scroll));
|
|
});
|
|
|
|
const showTopScrollHint = scrollVerticalOverflow && !scrollAtTop;
|
|
const showBottomScrollHint = scrollVerticalOverflow && !scrollAtBottom;
|
|
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const onScrollChangeRef = useRef(props.onScrollSectionChange);
|
|
useEffect(() => {
|
|
onScrollChangeRef.current = props.onScrollSectionChange;
|
|
}, [props.onScrollSectionChange]);
|
|
|
|
useEffect(() => {
|
|
const scrollerElement = scrollerRef.current;
|
|
strictAssert(scrollerElement, 'Expected scrollerRef.current to be defined');
|
|
|
|
const options: IntersectionObserverInit = {
|
|
threshold: 0, // 1px is visible (within margin)
|
|
rootMargin: `-${props.sectionGap}px 0px -${props.sectionGap}px 0px`,
|
|
root: scrollerElement,
|
|
};
|
|
|
|
type HistoryItem = { id: string; time: number };
|
|
const history = new Map<string, HistoryItem>();
|
|
let lastId: string | null = null;
|
|
|
|
const observer = new IntersectionObserver(entries => {
|
|
for (const entry of entries) {
|
|
const { id } = entry.target;
|
|
strictAssert(id, 'Observed element must have an id');
|
|
if (entry.isIntersecting) {
|
|
history.set(id, { id, time: entry.time });
|
|
} else {
|
|
history.delete(id);
|
|
}
|
|
}
|
|
|
|
const stack = Array.from(history.values());
|
|
const needle = maxBy(stack, x => x.time);
|
|
|
|
if (needle != null && needle.id !== lastId) {
|
|
lastId = needle.id;
|
|
onScrollChangeRef.current?.(needle.id);
|
|
}
|
|
}, options);
|
|
|
|
observerRef.current = observer;
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
}, [props.sectionGap]);
|
|
|
|
const observe: ScrollerSectionObserve = useCallback((element: Element) => {
|
|
const observer = observerRef.current;
|
|
strictAssert(observer, 'Expected observerRef.current to be defined');
|
|
observer.observe(element);
|
|
|
|
return () => {
|
|
observer.unobserve(element);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="FunScroller__Container">
|
|
<div
|
|
className={classNames(
|
|
'FunScroller__Hint',
|
|
'FunScroller__Hint--Top',
|
|
showTopScrollHint && 'FunScroller__Hint--Visible'
|
|
)}
|
|
/>
|
|
<div
|
|
className={classNames(
|
|
'FunScroller__Hint',
|
|
'FunScroller__Hint--Bottom',
|
|
showBottomScrollHint && 'FunScroller__Hint--Visible'
|
|
)}
|
|
/>
|
|
<div
|
|
ref={mergeRefs(scrollerRef, ref)}
|
|
className="FunScroller__Viewport"
|
|
// Nested scrollable elements should be focusable
|
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
tabIndex={0}
|
|
>
|
|
<ScrollerSectionObserveContext.Provider value={observe}>
|
|
<div ref={scrollerInnerRef} className="FunScroller__ViewportInner">
|
|
{props.children}
|
|
</div>
|
|
</ScrollerSectionObserveContext.Provider>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export type FunScrollerSectionProps = Readonly<{
|
|
id: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
children: ReactNode;
|
|
}>;
|
|
|
|
export function FunScrollerSection(
|
|
props: FunScrollerSectionProps
|
|
): JSX.Element {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const observe = useContext(ScrollerSectionObserveContext);
|
|
strictAssert(observe, 'Expected observe to be defined');
|
|
|
|
useEffect(() => {
|
|
const element = ref.current;
|
|
strictAssert(element, 'Expected ref.current to be defined');
|
|
return observe(element);
|
|
}, [observe]);
|
|
|
|
return (
|
|
<section
|
|
ref={ref}
|
|
id={props.id}
|
|
className={classNames('FunScroller__Section', props.className)}
|
|
style={props.style}
|
|
>
|
|
{props.children}
|
|
</section>
|
|
);
|
|
}
|